Текст
                    The Software
Optimization Cookbook
High-Performance Recipes for IA-32
Platforms
Second Edition
Richard Gerber
AartJ.CBik
Kevin B. Smith
XinminTian
Intel
PRESS


Ричард Гербер Арт Бик Кевин Смит Ксинмин Тиан Оптимизация ПО Сборник рецептов Москва • Санкт-Петербург - Нижний Новгород - Воронеж Ростов-на-Дону - Екатеринбург - Самара - Новосибирск Киев - Харьков - Минск 2010
ББК 32.973.2-018 УДК 004.41 0-62 Гербер Р., Бик А., Смит К., Тиан К. 0-62 Оптимизация ПО. Сборник рецептов. — СПб.: Питер, 2010. — 352 с: ил. — (Серия «Библиотека программиста»). ISBN 978-5-388-00131-3 Эта книга содержит практические рецепты, позволяющие разработчикам увеличить производительность приложений на платформе Intel. На простых примерах ведущие эксперты компании Intel объясняют читателю, как правильно строить алгоритмы, управлять распределением памяти, прогнозировать ветвление, использовать SIMD-инструкции и многопоточность, производить вычисления с плавающей точкой и многое другое. Книга будет интересна всем разработчикам, желающим освоить передовые технологии и улучшить качество кода. ББК 32.973.2-018 УДК 004.41 Все права защищены. Никакая часть данной книги не может быть воспроизведена в какой бы то ни было форме без письменного разрешения владельцев авторских прав. Информация, содержащаяся в данной книге, получена из источников, рассматриваемых издательством как надежные. Тем не менее, имея в виду возможные человеческие или технические ошибки, издательство не может гарантировать абсолютную точность и полноту приводимых сведений и не несет ответственности за возможные ошибки, связанные с использованием книги. ISBN 0976483211 (англ.) © 2006, Intel Press ISBN 978-5-388-00131-3 © Перевод на русский язык © ООО Издательство «Питер», 2010 © Издание на русском языке, оформление ООО Издательство «Питер», 2010
Предисловие 15 Часть I. ИНСТРУМЕНТЫ И КОНЦЕПЦИИ ПОВЫШЕНИЯ ПРОИЗВОДИТЕЛЬНОСТИ Глава 1. Введение 20 Глава 2. Тест производительности 25 Глава 3. Инструменты повышения производительности 33 Глава 4. Горячие точки 49 Глава 5- Архитектура процессоров 55 Часть П. ПРОБЛЕМЫ ПРОИЗВОДИТЕЛЬНОСТИ Глава 6. Алгоритмы 68 Глава 7. Переходы 84 Глава 8. Память 99 Глава 9. Циклы 129 Глава 10. Медленные операции 143 Глава 11. Операции с плавающей точкой 156 Глава 12. Технология SIMD 169 Глава 13. Автоматическая векторизация 186 Глава 14. Специфические для процессоров варианты оптимизации 212 Глава 15. Основы многопроцессорной обработки 224 Глава 16. Реализация многопоточности средствами ОрепМР 237 Глава 17. Очередь заданий и другие сложные темы 257
Часть III. РАЗРАБОТКА И ОПТИМИЗАЦИЯ ПРИЛОЖЕНИЙ Глава 18. Конкретный пример поточной обработки в видеокодеке 286 Глава 19. Разработка с прицелом на производительность 306 Глава 20. Сводим все вместе — базовые варианты оптимизации 313 Глава 21. Сводим все вместе — последние десять процентов 325 Литература 334 Алфавитный указатель 338
Предисловие 15 Благодарности 16 Помощь читателей 16 От издателя перевода 17 Часть I. ИНСТРУМЕНТЫ И КОНЦЕПЦИИ ПОВЫШЕНИЯ ПРОИЗВОДИТЕЛЬНОСТИ Глава 1. Введение 20 Оптимизация программного обеспечения 21 Заблуждения относительно оптимизации 21 Процесс оптимизации программного обеспечения 23 Основные моменты 24 Глава 2. Тест производительности 25 Атрибуты теста производительности 26 Воспроизводимость (обязательный атрибут) 26 Репрезентативность (обязательный атрибут) 27 Простота применения (обязательный атрибут) 27 Проверяемость (обязательный атрибут) 27 Измерение прошедшего времени (желательный атрибут) 27 Полнота охвата (в зависимости от ситуации) 28 Точность (в зависимости от ситуации) 28 Примеры тестов производительности 28 Основные моменты 32
Глава 3. Инструменты повышения производительности 33 Механизмы отсчета времени 33 Оптимизирующие компиляторы 35 Использование компиляторов C++ и Fortran производства Intel ... 35 Оптимизация для конкретных процессоров 36 Написание функций для конкретного процессора 37 Другие варианты оптимизации компиляторов 39 Типы программных профилировщиков 39 Монитор производительности производства Microsoft 40 Анализатор производительности VTune™ 41 Выборка 41 Граф вызовов 43 Профилировщик codecov компилятора Intel 43 Профилировщик Microsoft Visual C++ 45 Выборка или инструментовка 45 Пробы и ошибки, здравый смысл и терпение 47 Основные моменты 48 Глава 4. Горячие точки 49 Причины появления горячих и холодных точек 49 Больше, чем просто время 51 Равномерное выполнение без горячих точек 52 Основные моменты 53 Глава 5. Архитектура процессоров 55 Функциональные блоки 56 Два чизбургера, пожалуйста! 56 Выборка и декодирование команд 59 Выполнение команд 61 Удаление 64 Регистры и память 65 Основные моменты 66 Часть II. ПРОБЛЕМЫ ПРОИЗВОДИТЕЛЬНОСТИ Глава 6. Алгоритмы 68 Вычислительная сложность 68 Выбор команд 69 Зависимость по данным и параллелизм команд 74 Требования к памяти 76 Параллельные алгоритмы 77
Универсальность алгоритмов 78 Выявление алгоритмических проблем 79 Основные моменты 83 Глава 7. Переходы 84 Поиск критичных переходов среди неверно предсказываемых 86 Шаг 1. Поиск неверно предсказываемых переходов 86 Шаг 2. Поиск горячих точек потери времени 86 Шаг 3. Определение процента неверно предсказываемых переходов 87 Окончательная проверка готовности 89 Различные типы переходов 90 Предсказуемость переходов 92 Удаление переходов с помощью команды CMOV 93 Удаление переходов с помощью масок 94 Удаление переходов с помощью команд min/max 96 Удаление переходов за счет дополнительной работы 96 Основные моменты 97 Глава 8. Память 99 Основы оптимизации памяти 100 Основная и виртуальная память . 101 Кэши процессора 101 Механизм кэширования 103 Аппаратная предвыборка 104 Программная предвыборка 105 Запись данных без использования кэшей — прямая запись 106 Проблемы памяти, влияющие на производительность 108 Принудительная загрузка кэша 108 Загрузка из-за недостаточной емкости кэша 109 Загрузка из-за конфликтов кэша 109 Эффективность кэша 110 Опережающая запись 111 Выравнивание данных 112 Компиляторы и выравнивание данных 113 Программная предвыборка 114 Выявление проблем памяти 116 Поиск случаев отсутствия страниц 116 Поиск проблем опережающей записи 118 Поиск промахов кэша уровня 1 119 Потенциальные улучшения 121
Решение проблем памяти 122 Основные моменты 128 Глава 9. Циклы 129 Зависимости по данным 130 Расщепление и слияние циклов 132 Чистка циклов 134 Развертывание циклов и свертывание развернутых циклов 135 Перестановка циклов 138 Вычисления инвариантов цикла 140 Инвариантные относительно цикла переходы 141 Инвариантные относительно цикла результаты 142 Основные моменты 142 Глава 10. Медленные операции 143 Медленные команды 143 Справочные таблицы 145 Системные вызовы 148 Простой системы 151 Основные моменты 154 Глава 11. Операции с плавающей точкой 156 Числовые исключения 156 Сброс в нуль и приравнивание денормализованных чисел нулю 160 Точность -. 161 Упакованный и скалярный режимы 165 Преобразование значения с плавающей точкой в целое, округление . 165 Функции округления 166 Тонкости обработки чисел с плавающей точкой 166 Преобразование значения с плавающей точкой в целое 167 Извлечение квадратного корня 167 Получение обратного значения от квадратного корня 168 Основные моменты 168 Глава 12. Технология SIMD 169 Знакомство с технологией SIMD 170 Технология ММХ™ 170 Потоковые SIMD-расширения 171
Использование технологии SIMD 172 Автоматическая векторизация 172 Библиотеки классов C++ 173 Внутренние команды компилятора 174 Подставляемый ассемблер 176 Достоинства и недостатки четырех вариантов использования технологии SIMD 176 Соображения по поводу технологии SIMD 178 Где имеет смысл использовать технологию SIMD 178 Выравнивание памяти 178 Организация данных 180 Выбор подходящего упакованного типа данных 182 Совместимость вычислений с помощью SIMD-команд и команд FPU-блока х87 184 Основные моменты 184 Глава 13. Автоматическая векторизация 186 Параметры компиляторов для векторизации 186 Наиболее часто используемые параметры компиляторов 186 Пример использования параметров компилятора 189 Подсказки компилятору для векторизации . 190 Часто используемые подсказки компилятору 190 Примеры подсказок компилятору 195 Советы относительно векторизации 197 Соображения по разработке и реализации 197 Использование диагностических сообщений при векторизации .... 199 Минимизация возможных эффектов совпадения имен и побочных эффектов векторизации 203 Стиль программирования 206 Целевые архитектуры 207 Основные моменты 210 Глава 14. Специфические для процессоров варианты оптимизации 212 32-разрядные архитектуры Intel 212 Процессор Pentium M 215 Кэш команд уровня 1 216 Декодирование команд 216 Латентность команд 217 Набор команд 218 Управляющий регистр для операций с плавающей точкой 219
Регистр состояния MXCSR 219 Кэш данных уровня 1 219 Предвыборка памяти 220 События процессора 220 Частичный останов регистров 220 Частичный останов регистра флагов 222 Основные моменты 223 Глава 15. Основы многопроцессорной обработки 224 Параллельное программирование 225 Управление программными потоками 227 Высокоуровневая поточная обработка средствами ОрепМР 227 Низкоуровневая поточная обработка 230 Многопоточные задания 231 Проблемы многопоточносги 232 Поточные компиляторы и инструменты 235 Основные моменты 236 Глава 16. Реализация многопоточносги средствами ОрепМР .... 237 Ключевые элементы спецификации ОрепМР 237 Многопоточная модель выполнения 242 Модель памяти в ОрепМР 243 Ограничения ОрепМР 247 Компиляция ОрепМР-программ 248 Автоматический параллелизм 250 Рекомендации по многопоточной обработке 253 Основные моменты 255 Глава 17. Очередь заданий и другие сложные темы 257 Очередь заданий — расширение Intel для ОрепМР 257 Модель очереди заданий 257 Конструкции tasq и task 260 Конкретный пример поточной обработки в программе п ферзей . . . 263 Конвейерный параллелизм на уровне потоков 268 Вложенный параллелизм 272 Многоуровневый параллелизм 276 Понятие привязки программных потоков 278 Понятие планирования циклов 280 Основные моменты 283
Часть III. РАЗРАБОТКА И ОПТИМИЗАЦИЯ ПРИЛОЖЕНИЙ Глава 18. Конкретный пример поточной обработки в видеокодеке 286 Исходная производительность кодировщика Н.264 287 Реализация параллелизма в кодировщике Н.264 287 Декомпозиция по заданиям и по данным 288 Параллелизм на уровне слоев 289 Параллелизм на уровне кадров 290 Реализация с использованием двух очередей слоев 291 Реализация с использованием очереди заданий 293 Производительность 295 Компромисс между повышенной скоростью и эффективным сжатием 296 Производительность на многопроцессорных системах с поддержкой гиперпоточности 297 Исследование производительности 298 Издержки многопоточности 302 Дальнейшая настройка производительности 303 Выводы относительно поточной обработки 304 Основные моменты 305 Глава 19. Разработка с прицелом на производительность 306 Перемещение данных 307 Память и параллелизм 307 Эксперименты 308 Алгоритмы 309 Основные моменты 311 Глава 20. Сводим все вместе — базовые варианты оптимизации 313 Легкодоступные варианты оптимизации 313 Приложение 314 Ход оптимизации 316 Тест производительности 316 Интерпретация результатов теста производительности 317 Улучшение преобразования чисел с плавающей точкой в длинные целые 318 Реализация параллелизма в алгоритме 319 Автоматическая векторизация 320 Реализация параллелизма на уровне команд при помощи внутренних команд компилятора 322
Сводка вариантов оптимизации 323 Основные моменты 323 Глава 21. Сводим все вместе — последние десять процентов .... 325 Скорость «света» 325 Повышение эффективности SIMD-команд 327 Последняя оптимизация 330 Выводы относительно вариантов оптимизации 332 Основные моменты 332 Литература 334 Книги и статьи 334 Интернет-ресурсы 337 Алфавитный указатель 338
Предисловие Первое издание этой книги продолжает оставаться одним из самых популярных из того, что предлагает издательство Intel Press. Отклики читателей показывают, что книга заполняет пробел между руководствами начального уровня, которые посвящены оптимизации программ в целом, и сложными справочниками, описывающими все аспекты архитектуры Intel. Однако появление технологии Intel Extended Memory 64 (Intel EM64T) и многопроцессорной обработки, наряду с растущей популярностью технологий гиперпоточности, ОрепМР и мультимедийных расширений наборов команд, привело к тому, что первое издание морально устарело. Продолжающийся спрос на вводный курс промежуточного уровня по этим темам подтолкнул издательство Intel Press попросить еще трех специалистов компании Intel объединить усилия с автором оригинального издания с целью создания расширенного и обновленного второго издания книги. В этом издании предлагаются пересмотренные рецепты создания высокопроизводительных приложений на платформах Intel. Используя простые объяснения и понятные примеры, авторы показывают, как решать проблемы производительности, связанные с обращениями к памяти, предсказаниями переходов, автоматической векторизацией, использованием SIMD-команд, многопоточностью и вычислениями с плавающий точкой. Разработчики программного обеспечения узнают, как использовать преимущества технологий Intel EM64T, многоядерной обработки, гиперпоточности, ОрепМР и мультимедийных расширений системы команд. Эта книга познакомит вас с растущей коллекцией программных инструментов, параметров компилятора и вариантов оптимизации кода, показывая эффективные пути повышения производительности программ для платформ Intel. Книга определенно будет полезна разработчикам программного обеспечения, которые хотят разобраться в самых современных технологиях повышения производительности и усовершенствовать свои навыки кодирования.
Благодарности Авторы благодарят всех, кто тем или иным образом внес свой вклад в написание данной книги или в создание компиляторов Intel и инструментов анализа производительности, среди этих людей: Зия Ансари (Zia Ansari), Пит Бейке (Pete Baker), Митч Бодарт (Mitch Bodart), Кристофер Борд (Christopher Bord), Марк Бакстон (Mark Buxton), Райн Карлсон (Ryan Carlson), Йен-Куанг Чен (Yen-Kuang Chen), Джошуа Чи (Joshua Chia), Мартин Корден (Martyn Corden), Роберт Кокс (Robert Сох), Вильям Демон третий (William E. Damon III), Макс Домейка (Мах J. Domeika), Милинд Гиркар (Milind Girkar), Коби Готтлейб (Koby Gottlieb), Грант Хаб (Grant Haab), Мухамед Хагигат (Mohammad Haghighat), Джей Хофлингер (Jay Hoeflinger), Джон Хольм (John Holm), Александр Исаев (Alexander Isaev), Майкл Джули (Michael Julier), Вей Ли (Wei Li), Кристофер Лишка (Christopher Lishka), Диана Кинг (Diana King), Кнут Киркегард (Knud Kirkegaard), Давид Крейцер (David Kreitzer), Тим Меттсон (Tim Mattson), Эрик Мур (Eric Moore), Ден Макри (Dan Macri), Андрей Нарайкин (Andrey Naraikin), Кеннан Нарайянян (Kannan Narayanan), Кларк Нельсон (Clark Nelson), Джон Нг (John Ng), Пауль Петерсон (Paul Peterson), Майкл Росс (Michael Ross), Хидеки Сайто (Hideki Saito), Санджив Шах (Sanjiv Shah), Эрнесто Су (Ernesto Su), Сара Сармиенто (Sara Sarmiento), Вильям Саваж (William Savage), Дейл Шутен (Dale Schouten), Давид Сехр (David Sehr), Ронак Сингал (Ronak Singhal), Кевин Смит (Kevin J. Smith), Стейси Смит (Stacey Smith), Крейг Столлер (Craig Stoller), Боб Валентин (Bob Valentine) и Ро- нен Зохар (Ronen Zohar). Отдельное спасибо за ценные замечания по черновым вариантам книги нашим экспертам, Тиму Карверу (Tim Carver), который прежде работал в Intel, Уолту Диксону (Walt Dixon) из GE Global Research, Ларсу Петеру Эндресену (Lars Petter Endresen) из Scandpower Petroleum Technology AS, Джеймсу Хилу (James H. Hill) из United States Postal Service, Брайану Кеннеди (Brian Kennedy) из Jeppesen/ Boeing, Джитенде Махишвари (Jitendra Maheshwari) из Zeesoft Inc., Кевину Ру- ланду (Kevin Ruland) из University of Kansas Natural History Museum и Роберту ван Энгельну (Robert van Engelen) из Florida State University Большую помощь в написании данной книги оказали сотрудники издательства Intel Press. В частности, мы хотели бы выразить признательность нашему редактору Давиду Спенсеру (David Spencer) и архитектору проекта Стюарту Голдштейну (Stuart Goldstein) за их любезное и профессиональное руководство на всех стадиях издательского процесса. Помощь читателей Для подготовки второго издания группа авторов тщательно переработала материал первого издания и обновила большую его часть. В частности Ричард Гербер обновил заключительную часть; Арт Бик обновил главы 2, 9 и 12 и добавил главу 13 по автоматической векторизации; Кевин Смит обновил главы с 3 по 8, 10, И и 14;
От издателя перевода 17 Ксинмин Тиан обновил главу 15 и добавил главы 16,17 и 18, посвященные реализации параллелизма средствами ОрепМР. Завершив работу над книгой, нам хотелось бы поддерживать ее в актуальном состоянии, отвечая на ваши вопросы и учитывая пожелания относительно уточнений. Поэтому мы разместили контактную информацию, найденные ошибки и дополнительные материалы на посвященном этой книге веб-сайте http://www. intel.com/intelpress/sum_swcb2.htm. Ричард Гербер, Кевин Смит, Арт Бик, Ксинмин Тиан От издателя перевода Ваши замечания, предложения и вопросы отправляйте по адресу электронной почты comp@piter.com (издательство «Питер», компьютерная редакция). Мы будем рады узнать ваше мнение! Подробную информацию о наших книгах вы найдете на веб-сайте издательства http://www.piter.com.
Инструменты и концепции повышения производительности
Введение В 1981 году компания IBM выпустила в продажу первый персональный компьютер. В нем использовался процессор Intel 8088, работавший на тактовой частоте 4,77 МГц. Двадцать пять лет спустя в наиболее мощных персональных компьютерах применяются двухъядерные процессоры Intel Pentium® Processor Extreme с тактовой частотой 3,46 ГГц — то есть скорость выросла более чем в 600 раз. Иначе говоря, вычисления, занимавшие в 1981 году более 10 минут, сейчас выполняются менее чем за секунду. Так почему же нам по-прежнему нужна оптимизация программного обеспечения? Современное программное обеспечение сложнее и поддерживает больше функциональных возможностей, чем простые приложения более чем двадцатилетней давности с текстовым интерфейсом. От игр и обучающих приложений до баз данных и операционных систем — везде требуется больше вычислительных ресурсов, чем ранее, и сегодняшнему программному обеспечению их также не хватает. Производительность программного обеспечения может сильно меняться в зависимости от выбранного разработчиком способа решения задачи или реализации функциональной возможности. Хорошо оптимизированные приложения могут выполняться в десятки раз быстрее, чем приложения, написанные плохо. Сочетание эффективных алгоритмов и хорошо спроектированных реализаций ведет к созданию отличных высокопроизводительных приложений. Эта книга о том, как заставить ваши приложения работать быстрее на том же самом аппаратном обеспечении, и о том, как масштабировать их с учетом будущих аппаратных платформ, которые, возможно, будут иметь больше ядер и более быструю память. В первой части книги рассказывается об инструментах, концепциях и методах анализа приложений с целью обнаружения тех фрагментов кода, которые нуждаются в улучшении. Во второй части обсуждаются основные проблемы производительности, их выявление, способы улучшить положение. В заключительной части мы займемся разработкой высокопроизводительного приложения «с нуля» и на примере оптимизации тестового приложения выполним анализ всего процесса оптимизации, дающего 20-кратное улучшение.
Заблуждения относительно оптимизации 21 Оптимизация программного обеспечения При обсуждении нового продукта в первую очередь обычно касаются его функциональных возможностей: сколько наиболее привлекательных функциональных возможностей нужно включить в продукт, чтобы его захотели купить. К сожалению, производительность при этом часто не принимается во внимание. В статье из журнала PC Magazine1 в виде таблицы были представлены результаты сравнения трех архиваторов. В этих результатах учитывался лишь коэффициент сжатия, тогда как скорость работы утилит вообще не упоминалась. Покупатели же, безусловно, заинтересованы не только в меньшем размере файлов, но и в быстром сжатии информации. Как правило, оптимизация программного обеспечения выполняется в конце процесса разработки, и уделяется ей ровно столько времени, сколько остается — а это обычно очень мало. Если откладывать оптимизацию до завершения разработки, то достичь значительного повышения производительности гораздо труднее. Здесь все так же, как с реализацией новых функциональных возможностей и поиском ошибок: чем раньше начнет решаться проблема, тем легче ее будет решить, и тем лучше может быть решение. Выпуск пакетов исправлений для программного обеспечения — не лучшая методика оптимизации. Для того чтобы быть полностью уверенным, что ваше приложение полностью укомплектовано и работает с ожидаемой производительностью, нужно относиться к производительности так же, как к любой другой функциональной возможности. Следовательно, нужно указывать показатель производительности в проектной документации, чтобы разработчики знали об этом еще до начала программирования. Производительность приложения должна учитываться с самого начала разработки (чтобы не пришлось потом решать проблемы при ее завершении), и отдел контроля качества должен проводить тесты производительности приложения в течение всего процесса разработки (наряду со всеми остальными функциональными тестами). Заблуждения относительно оптимизации Оптимизация программного обеспечения не обходится без заблуждений и недоразумений. Вот восемь из них. Заблуждение 1. Нельзя повысить производительность приложения до его запуска. Это все равно, что начать первое тестирование приложения после того, как работа над ним полностью завершена. Можете себе представить, как трудно было бы найти таким способом ошибку? То же самое и с производительностью. Постоянные эксперименты с производительностью, мониторинг и последовательное совершенствование кода делают процессы поиска проблем и повышения производительности 1 PC Magazine, June 22, 2001, «Performance Tests: Compression Comparison». В статье дается сравнение показателей уровня сжатия методами LZH, RAR и ZIP.
22 Глава 1 • Введение проще и эффективнее. Отложив вопросы быстродействия на потом, вы потратите на их решение гораздо больше сил и времени. Заблуждение 2. Сначала производится сборка приложения, а уже затем принимается решение, на каком компьютере оно будет выполняться. А если потом окажется, что приложение не работает ни на одном из компьютеров или работает только на самых дорогих? При таком подходе слишком многое отдается на волю случая. Планирование, оценка и оптимизация на всех стадиях разработки приложения приведут к созданию высокопроизводительных приложений с наименьшими усилиями. Заблуждение 3. Оптимизация нацеливается на отказ от тех функциональных возможностей, которые работают слишком медленно. Тенденция снабжать приложение средствами настройки (которые позволяют снизить качество или вовсе отключить те или иные функциональные возможности для повышения быстродействия) наиболее часто прослеживается в играх, где имеются кнопки для отказа от реалистичного освещения или затейливых аудиоэффектов. Сама по себе идея неплоха, потому что уменьшение объема выполняемой работы — это всегда хороший способ оптимизации. Однако здесь есть подвох. Вместо того чтобы тратить время на оптимизацию, вы тратите его на добавление новой кнопки, отключающей определенные возможности приложения. Задайте себе вопрос: от чего вы получите больший эффект — от отключения функциональных возможностей или от повышения их быстродействия? Заблуждение 4. Приложение отлично работает на моем компьютере — этого вполне достаточно. В распоряжении разработчиков, как правило, имеются современные и быстрые компьютеры, характеристики которых значительно превосходят характеристики компьютеров пользователей. Существует два простых пути разрешения этой проблемы: пересадить разработчиков за более медленные обычные компьютеры или заставить отдел контроля качества тестировать производительность на широком спектре различных компьютеров и докладывать о полученных результатах. Заблуждение 5. Для разработки достаточно отладочных сборок. Иногда при разработке приложения оптимизирующие параметры компилятора не задаются (чтобы максимально упростить отладку). К сожалению, без компиляторной оптимизации нельзя провести анализ производительности или ее мониторинг. Очень важно, чтобы на протяжении всего процесса разработки использовался оптимизирующий компилятор с включенными параметрами оптимизации. Это помогает на раннем этапе выявить проблемы производительности, а также возможные функциональные ошибки, которые могут проявиться при компиляторной оптимизации. Заблуждение 6. Производительность требует программирования на языке ассемблера. Программирование на ассемблере часто позволяет повысить производительность, но это не обязательно. Раньше компиляторы не поддерживали новых команд и не очень хорошо справлялись с оптимизацией, поэтому ручное кодирование на ассемблере всегда выигрывало у компиляторов. Однако с тех пор процессоры и компиляторы сильно изменились.
Процесс оптимизации программного обеспечения 23 Тем не менее, ассемблер иногда используется, но в основном для изучения вариантов оптимизации, предлагаемых компилятором (или для выявления их недостатков). Компилятор все еще можно превзойти, программируя на ассемблере, но для этого наверняка потребуется прибегать к таким допущениям и сокращениям, на которые компилятор не способен. Перед тем как переходить к ассемблеру, постарайтесь заставить компилятор создать более эффективный код. Заблуждение 7. Первым делом нужно запрограммировать функциональные возможности, а уже затем их оптимизировать, если останется время. К сожалению, это распространенное заблуждение, и в конце всегда оказывается, что времени почти не осталось. Помните, что быстродействие хорошо продается и должно рассматриваться как одна из главных функциональных возможностей. Оптимизация производительности в течение всего процесса разработки занимает меньше времени и дает лучшие результаты, чем оптимизация, отложенная до конца разработки. Заблуждение 8. Для оптимизации требуется специалист по процессорам. Большинство инженеров, участвующих в создании процессоров, знают досконально только какую-то небольшую часть процессора. Но так как они не зарабатывают себе на жизнь созданием программного обеспечения (а заняты разработкой процессоров), то они не всегда являются лучшими советчиками при оптимизации приложений. Вся информация по микроархитектуре процессора, которая может понадобиться для оптимизации программы, хорошо документирована в технических руководствах Intel [17,18,19, 20]. Процесс оптимизации программного обеспечения Обычно процесс оптимизации программного обеспечения начинается с разработки теста производительности, который применяется для объективного измерения быстродействия всего приложения, или той его части (алгоритма), которая оптимизируется. Имея на руках такой тест, можно приступать к оптимизации. Первый шаг — найти горячие точки, то есть те фрагменты кода, которые расходуют основную часть времени приложения; это чем-то напоминает поиск слабого звена в цепи. Далее проводится исследование горячей точки с целью обнаружить причину ее возникновения: медленные операции обращения к памяти, неэффективные алгоритмы, большое количество итераций в циклах, проблемы с предсказанием переходов, наличие медленных команд — вот только некоторые из возможных причин. Когда причина возникновения горячей точки известна, можно приступать к разработке и реализации решения, позволяющего исправить ситуацию. Так как не все изменения в коде ведут к повышению производительности, следует использовать тест для подтверждения факта ее повышения в результате внесенных изменений. Рисунок 1.1 иллюстрирует эти шаги.
24 Глава 1 • Введение Создание теста i ^^ч Отыскание i \. Определение i ^v Модификация производительности I ./ горячих точек I ./ причин I ./ приложения 4\ Ж Повторное I тестирование | производительности Рис. 1.1. Процесс оптимизации программного обеспечения Данная книга разделена на три части, чтобы их было проще пропускать. Если вы уже знакомы с такими инструментами, как оптимизирующие компиляторы или анализатор производительности VTune™, а также с концепциями наподобие горячих точек, можете переходить непосредственно к части II; Если у вас уже есть некоторый опыт оптимизации, вы, возможно, захотите пропустить часть II и перейти сразу к части III, где изложенные в этой книге методики применяются для оптимизации тестовых приложений. Примеры, встречающиеся в данной книге, предназначены не для проверки ваших знаний, а для того, чтобы научить вас основным приемам оптимизации наиболее доступным способом. Лучшее место для проверки ваших знаний — это ваши собственные приложения и алгоритмы. Напишите тест производительности и начинайте применять навыки оптимизации на практике! Ваш тест подскажет, удалось ли вам удачно провести оптимизацию, но помните при этом, что такого понятия, как «самый быстрый код», не существует. Основные моменты Когда начнете оптимизацию вашего приложения, имейте в виду следующие моменты: а Оптимизация программного обеспечения — это непрерывный процесс, который начинается с этапа проектирования и длится на протяжении всего цикла разработки приложения. □ Не обращайтесь сразу же к программированию на ассемблере. Сначала всегда старайтесь заставить компилятор сгенерировать более эффективный код.
Тест производительности Тест производительности — это программа или процесс, используемый для: □ объективной оценки производительности приложения; □ обеспечения повторяемости поведения приложения (для работы с инструментами анализа производительности). Тест производительности выполняется до и после проведения оптимизации с целью выявить изменения в производительности. Если оптимизация не удается и производительность снижается, то программист может отказаться от неудачной оптимизации и попробовать что-либо другое. В случае повышения производительности величину этого повышения можно сравнить с ожидаемыми результатами, чтобы убедиться в успешности оптимизации. В идеальном случае производительность будет постоянно повышаться с каждой оптимизацией, но так, к сожалению, бывает не всегда. Задачей теста производительности является выявление фактов повышения и понижения производительности, чтобы можно было избежать таких модернизаций, которые затормозят выполнение приложения. Тест производительности используется также совместно с инструментами анализа производительности. Инструменты анализа работают лучше всего тогда, когда приложение можно прогнать несколько раз совершенно одинаковым способом, тестируя таким образом одни и те же фрагменты кода. Поскольку инструменты анализа производительности обеспечивают лишь запуск и анализ приложений, только программист отвечает за то, чтобы программа выполнялась каждый раз одинаково — именно на это рассчитан тест производительности. Один или несколько тестов производительности могут служить как для измерения производительности, так и помощью инструментам анализа — все зависит от того, что лучше работает и проще в использовании. Можно применять как готовые тесты производительности, так и написанные специально для тестирования вашего приложения. Поскольку написание собственного теста означает лишнюю работу, неплохо сначала поискать готовую программу
26 Глава 2 • Тест производительности Существует много стандартных тестов, таких как ТРС-С1, 3D WinBench2 и SPEC CPU20003. Эти стандартные для отрасли тесты измеряют производительность программного и аппаратного обеспечения и выдают некий показатель или набор показателей, которые могут служить для выявления изменений в производительности. Использование стандартных тестов дает дополнительное преимущество: вы можете сравнить производительность одного и того же набора приложений на различных платформах (в маркетинговых целях). Однако иногда стандартные тесты слишком обобщенные, их установка сложна или занимает неоправданно много времени, что делает более удачным выбором тест, специально созданный для конкретной ситуации. Какую бы комбинацию стандартных и специальных тестов вы ни использовали, убедитесь в том, что процесс проведения теста прост и занимает не слишком много времени (чтобы его можно было проводить часто и без проблем). Атрибуты теста производительности Обдумайте следующие атрибуты тестов, чтобы определить, какие из них вы будете использовать. Воспроизводимость (обязательный атрибут) Тест производительности, при каждом проведении выдающий разные результаты, не слишком полезен. Остановка всех других приложений, наподобие антивирусов, почтовых программ и драйверов факса, помогает получить более устойчивые результаты, однако различные переходные процессы (связанные с состоянием кэша, временными файлами, предварительно вычисленными значениями и индексами, и т. д.) могут по-прежнему приводить к тому, что приложение при последовательных прогонах будет выполняться по-разному. Более того, многие обстоятельства, неподконтрольные пользователю и приложению (такие как кэширующие контроллеры жестких дисков, фоновые задачи операционной системы и различное аппаратное обеспечение), также влияют на производительность системы и практически наверняка приведут к получению разных значений производительности. Усреднение результатов по нескольким прогонам иногда помогает получить более устойчивые результаты измерений, но такое решение не идеально, поскольку оно может учесть то, что никак не связано с вашим приложением, например, получение входящего факса (если только ваше приложение не является драйвером факса). Более удачным вариантом может быть выбор лучшего показателя производительности по результатам нескольких прогонов (а не среднего или худшего). 1 Тест ТРС-С написан в Transaction Processing Performance Council. Дополнительную информацию см. по адресу http://www.tpc.org/. 2 WinBench написан в Ziff Davis Media. Дополнительную информацию см. по адресу http://www.zdnet.com/. 3 Дополнительную информацию см. по адресу http://www.spec.org/.
Атрибуты теста производительности 27 Лучший показатель явно исключает влияние вышеупомянутого факса, но скрывает такие вещи, как эффект прогрева кэша (который, возможно, тоже необходимо учитывать). Самый лучший вариант — это разобраться в переходных процессах и создать тест, проверяющий именно то, что вы хотите оптимизировать. Репрезентативность (обязательный атрибут) Тест производительности должен следовать типичному маршруту выполнения кода приложения, чтобы можно было анализировать и оптимизировать самые обычные ситуации. Анализ ошибочных ситуаций и нетипичных случаев приводит к обманчивым показателям производительности, последующему их включению в анализ и к бесполезным попыткам оптимизации. Лучшие тесты производительности имитируют использование приложения клиентами, поскольку только тогда вы можете быть уверены, что оптимизируете именно то, что имеет значение. Иногда может возникнуть искушение задействовать набор тестов отдела контроля качества программ. Однако учтите, что тесты контроля качества обычно ориентированы на оценку граничных условий, ошибочных ситуаций и прочих нетипичных случаев, которые идут вразрез с тем, что обычно делают пользователи (и которые поэтому нет смысла оптимизировать). Как правило, тесты контроля качества служат для оценки функциональности программного обеспечения, а не его производительности, что делает их мало пригодными для использования в качестве тестов производительности. Простота применения (обязательный атрибут) Простота применения означает, что, как минимум, тест легко установить, с ним просто работать, он выполняется быстро и дает легко интерпретируемые результаты. Выполнение теста и точная интерпретация его результатов должны занимать как можно меньше времени и быть как можно проще. Чем легче будет проводить тест, тем чаще он будет выполняться разными людьми, и тем больше появится шансов на ранней стадии выявить проблемы производительности. Проверяемость (обязательный атрибут) Необходимо иметь возможность проверить точность теста и выдаваемых им результатов. Главным образом — точность теста. Чрезвычайно обидно потратить время на оптимизацию какой-то части приложения, а затем обнаружить, что сам тест был некорректным. Помните, что быстродействующее приложение, выдающее неверные результаты, бесполезно. Измерение прошедшего времени (желательный атрибут) Измерение прошедшего времени, хотя и выполняется очень часто, является не единственным возможным показателем теста производительности.
28 Глава 2 • Тест производительности Существуют и другие показатели производительности программ. Объем расходуемой памяти, количество миллисекунд на обработку одного кадра, количество пиков в секунду или максимальное количество пользователей, которое может обслуживать система — любой из этих показателей может измеряться при тестировании производительности. Годится любой показатель, который характеризует производительность приложения. Иногда показатели, наподобие количества пиков в секунду (или другие общепринятые в отрасли показатели), для ваших целей могут оказаться лучше (чем прошедшее время), поскольку дают возможность прямого сравнения с продуктами конкурентов и использоваться в маркетинговых целях. Полнота охвата (в зависимости от ситуации) Тест производительности должен касаться только обычного пути выполнения кода или (что более важно) только тех путей выполнения, которые необходимо оптимизировать. Создание теста, проверяющего ошибочные условия или другой некритичный для производительности код — пустая трата времени. В некоторых случаях (драйверы и небольшие прототипы приложений) для производительности критично все приложение целиком — в этих случаях желательно полностью охватить тестом все аспекты приложения. Точность (в зависимости от ситуации) Оптимизированные варианты кода закрепляются в приложении только в том случае, если они приводят к удовлетворительному повышению производительности, поэтому тесты производительности должны иметь точность, позволяющую зафиксировать такое повышение. Обычно точности в один-два процента повышения производительности вполне достаточно, поскольку слишком высокая точность может привести к путанице. Сказать, что на выполнение чего-либо требуется от 18 001 119 464 до 18 784 514 894 тактов — совсем не так информативно, как сказать, что требуется 12,2 секунды. Примеры тестов производительности Данный раздел иллюстрирует использование тестов производительности двумя примерами. Пример 2.1. Тест производительности сжатия Иногда тестирование может быть таким же простым, как использование секундомера для измерения времени выполнения вашего приложения при обработке им нескольких наборов данных и запись результатов в таблицу. Не упускайте возможность сделать тест как можно проще.
Примеры тестов производительности 29 Задача Создайте тест производительности для программы HUFF.EXE, размещенной на вебсайте этой книги. В программе для сжатия файла используется кодирование Хаффмана. Решение В первую очередь необходимо определить, что измерять. Прошедшее время и степень сжатия кажутся очень хорошими показателями. Для того чтобы тест был репрезентативным, необходимо использовать файлы разного размера и разной степени сжимаемости. Для записи результатов теста используйте таблицу, в которой от одного прогона к другому будут отличаться только значения времени (табл. 2.1). Таблица 2.1. Простая таблица для регистрации показателей тестирования программы HUFF.EXE JPEG-файл Mars.jpg размером 510 272 байт Текстовый файл Constitution5.txt размером 559 140 байт Прогон 1 4,5 секунд, 484 777 байт 2,1 секунд, 339 969 байт Прогон 2 Прогон 3 После многократных последовательных прогонов теста значения времени оказываются очень близкими (это означает, что на результат почти не влияли какие- либо переходные процессы). На случай «неожиданного прихода факса» вы должны указать, что с каждым файлом тест должен быть прогнан по два раза, а если окажется, что значения полученных результатов заметно отличаются, то необходимо проводить дополнительные тесты вплоть до получения близких результатов. Пример 2.2. Тест производительности сортировки Предположим, что в ходе разработки части большого приложения ваш шеф поручил вам реализовать алгоритм, который сортирует первые п элементов массива случайных чисел с плавающей точкой двойной точности. Из старого учебника вы помните, что один из способов сортировки массива заключается в повторяющихся перестановках неупорядоченных элементов. Эта концепция вдохновляет вас на первую реализацию С-функции sort К): double a[N]; void sortKint n) { int i.j; for (i = 0; i < n-1; i++) { for (j = i+1; j < n; j++) { if (a[i] > a[j]) { double tmp = a[i]; a[i] - a[j];
30 Глава 2 • Тест производительности a[j] = tmp; } } } } Когда вы с гордостью показываете свое решение вашему коллеге, он напоминает вам, что в стандартной С-библиотеке имеется эффективный алгоритм сортировки, реализованный в виде функции qsort(). Эта функция может быть использована для альтернативной реализации вашей функции sort2( ): int cmp(double *dl. double *d2) { if (*dl < *d2) return -1; if (*dl > *d2) return +1; return 0; } void sort2(int n) { qsort(a, n, sizeof(double), cmp); } Для того чтобы определить, какой из этих двух алгоритмов лучше, вы создаете тест производительности. Поскольку приложение имеет дело со случайными числами, вы решаете в качестве репрезентативного набора входных данных использовать простой набор случайных чисел с плавающей точкой и не анализировать производительность сортировки для уже отсортированных или почти отсортированных массивов. /* заполняем массив */ for (1 =0; i < n; i++) { a[i] = (double) randO; } В качестве характеристики длительности сортировки массива вы задействуете такты системных часов (см. главу 3). Просто запустите команду rdtsc до и после вызова алгоритма сортировки и вычислите разность двух полученных 64-разрядных значений. По определению макроса MYS0RT будет производиться выбор метода сортировки. unsigned _int64 tickl, tick2; /* сортировка по времени */ __asm{ rdtsc mov dword ptr tickl, eax mov dword ptr tickl+4,edx }
Примеры тестов производительности 31 #ifdef MYS0RT sortl(n); #else sort2(n); #endif ___asm{ rdtsc mov dword ptr tick2. eax mov dword ptr tick2+4,edx } printf(«length Xd. ticks *I64u\n». n. tick2-tickl); Важной частью теста производительности является проверка, действительно ли массив отсортирован: /* результат проверки */ for (i = 0; i < n-1; i++) { if (a[i] > a[i+l]) printf(«error XI f Xlf\n». a[i], a[i+l]): exit(l); Путем замеров времени выполнения обеих реализаций сортировки для массивов разной длины от 0 до 64 вы обнаруживаете, что ваше первоначальное решение имеет ту же производительность, что и метод стандартной С-библиотеки (как показано на рис. 2.1). 16 24 32 40 48 56 Размер массива Рис. 2.1. Такты системных часов при сортировке малых массивов
32 Глава 2 • Тест производительности Однако вас несколько беспокоят показатели при размерах массивов свыше сорока. Поэтому перед тем как помчаться к шефу со своим решением, вы проводите тесты для массивов гораздо больших размеров. Результаты показаны на рис. 2.2. со КТО Р 3 X о с: с. ^ 20 18 16 14 12 10 8 6 4 2 0 О 8 16 24 32 40 48 56 64 Размер массива Рис. 2.2. Такты системных часов при сортировке больших массивов Из этого графика очевидно, что алгоритм, используемый в методе qsort(), имеет значительно лучшие вычислительные возможности, чем ваше решение, то есть время его выполнения в зависимости от объема входных данных растет не так быстро. После консультаций с другими программистами вы выясняете, что алгоритму сортировки придется работать в основном с массивами размером около тысячи элементов. Следовательно, результаты теста настоятельно рекомендуют использовать алгоритм сортировки стандартной С-библиотеки (реализация sort2()) вместо вашего метода сортировки (реализация sort К)). Основные моменты Вспомните следующие основные моменты, когда начнете оптимизировать производительность при помощи теста производительности: □ Тест производительности — это программа или процесс, который используется для измерения производительности приложения и анализа производительности. □ Тест производительности должен быть, по крайней мере, воспроизводимым, репрезентативным, простым в применении и проверяемым. □ Для некоторых типов приложений уже существуют тесты производительности. Для других приложений может понадобиться написать один или более нестандартных тестов производительности. sorfl sort2
Инструменты повышения производительности Есть три основных инструмента повышения производительности: □ Механизмы отсчета времени. □ Оптимизирующие компиляторы. □ Программные профилировщики. Механизмы отсчета времени Секундомер — это простейший механизм отсчета времени. Секундомер может быть обычным (механическим или электронным) или программным (таким как утилита командной строки time операционной системы UNIX1). На сайте данной книги размещена программа timeC.exe для запуска из командной строки; она выводит количество затраченных тактов процессора и миллисекунд. Пример результата работы программы timeC.exe выглядит так: C:\dev\huff> timeC huff.exe constitution.txt Huffman Coding 'constitution.txt' Compressed file 'constitution.txt.huff Elapsed CPU clocks: 301165274. 210 ms Программные секундомеры, наподобие timeC.exe, хорошо работают при тестировании программ, выполняющихся дольше, чем несколько секунд. Если время выполнения меньше, то потери, связанные с работой самой программы-секундомера, становятся существенной частью общего времени выполнения. Когда требуется дополнительная точность, приходится встраивать вызовы функции отсчета времени непосредственно в само приложение. В табл. 3.1 показаны обычные типы таймеров и их атрибуты. 1 Программа 11 me в Microsoft Windows служит для вывода и настройки текущего времени, в отличие от UNIX-версии, которая выводит время работы приложения. 3
34 Глава 3 • Инструменты повышения производительности Функции отсчета времени могут использоваться для измерения времени выполнения всего приложения или любой его части, поскольку вы можете разместить вызовы этих функций в любом месте кода и вызывать их столько раз, сколько хотите. Эти функции чаще всего служат для хронометража кода инициализации, основных алгоритмов, времени ожидания и т. п. В примере кодирования Хаффмана из главы 2 функция отсчета времени могла бы записывать сколько времени уходит на чтение файла, на построение массива частот, на создание очереди приоритетов и т. д. Наличие дополнительной временной информации помогает при анализе производительности. В примере сортировки из главы 2 команда RDTSC использовалась для анализа, какая из двух реализаций лучше. Таблица 3.1. Функции отсчета времени, используемые при измерении производительности Таймер Функция С-библиотеки времени выполнения Мультимедийный таймер Windows Такты 32-разрядного процессора Округленные продолжительность и точность 73 года ±1 секунда Примерно 49 дней ±10 миллисекунд 1,26 секунды ±0,001 мкс1 (процессор с тактовой частотой 3,4 ГГц) Пример кода time_t StartTime, ElapsedTime; StartTime = time(NULL); <... your code ...> ElapsedTime - time(NULL) - StartTime; DWORD StartTime, ElapsedTime; StartTime = timeGetTimeO; <... your code ...> ElapsedTime = timeGetTimeO - StartTime; printf («Time in ms И», ElapsedTime); DWORD StartTime. ElapsedTime; _asm { RDTSC mov StartTime, eax } <... your code ...> _asm { RDTSC sub eax, StartTime mov ElapsedTime, eax } printf («Time in CPU clocks %d», 1 Зависит от управления питанием и внеочередного выполнения команд.
Оптимизирующие компиляторы 35 Таймер Такты 64-разрядного процессора Округленные продолжительность и точность Примерно 172 года +0,001 мкс1 (процессор с тактовой частотой 3,4 ГГц) Пример кода int64 StartTime, EndTime; _asm { RDTSC mov DWORD PTR StartTime, eax mov DWORD PTR StartTime+4, edx } <... your code ...> _asm { RDTSC mov DWORD PTR EndTime, eax mov DWORD PTR EndTime+4, edx } printf («Time in CPU clocks %I64d». Оптимизирующие компиляторы Самым удобным способом повышения производительности является использование оптимизирующего компилятора. Оптимизирующие компиляторы значительно изменились за последние годы; хороший компилятор может помочь вам автоматически задействовать новейшие функциональные возможности процессора и стратегии оптимизации, при этом вам даже не придется открывать руководство по процессору. Для повышения производительности всегда включайте параметры оптимизации компилятора. Этот необязательный вариант настройки компилятора помогает выявлять проблемы оптимизации на ранней стадии, когда их легче устранить. Параметры оптимизации компилятора следует отключать только тогда, когда это необходимо для отладки. Поскольку компиляторы постоянно развиваются, тщательно изучите документацию компилятора, чтобы разобраться в оптимизационных параметрах, прагмах и функциональных возможностях, касающихся производительности. Использование компиляторов C++ и Fortran производства Intel Компиляторы C++ и Fortran производства Intel имеют широкий диапазон средств оптимизации, которые используют новейшие возможности процессоров и новейшие стратегии оптимизации. В среде Microsoft Visual C++ .NET 1 Зависит от управления питанием и внеочередного выполнения команд.
36 Глава 3 • Инструменты повышения производительности компиляторы Intel могут стать высокопроизводительной заменой компилятора Microsoft (для этого необходимо соответствующим образом изменить проект, как показано на рис. 3.1). Компиляторы Intel имеются также и для Linux (с теми же возможностями, что и версии для Windows). Подробности, касающиеся компиляторов C++ и Fortran производства Intel, ищите в документации на эти компиляторы [16]. *■ [design] - Vector.cpp File Edit View Project guild Qebug lools Window Help p - |T3 - fi£ H £P к Щь Ш \ * ■£;•£►:► Debug Vector.cpp | Ш цр} Solution 'Vector' (1 project) < l> X (Globals) 3^ ~в ЩЩШ ByiJd Rebuild Clean Jt Project Only Ш r| "stdafx.h" [100], b[100]; t(void) { Add Add Reference... Add Web Reference... Set as Startup Project Debug У Save Vector ■ ^K Kemove Rename ^1 Properties 1|Д| Convert to use InteJ(R) C++ Project System |;i<100;i++) { = a[i-l] * 7.0; J;i<100;i++) { sin{ a[i] ); Done Clean: 1 succeeded, 0 failed, 0 skipped I Рис. З.1. Изменение проекта Microsoft Visual C++ .NET для использования компилятора Intel Оптимизация для конкретных процессоров Компиляторы C++ и Fortran производства Intel поддерживают новые процессоры за счет всех новых команд и правил планирования кода. При использовании команд, специфичных для конкретного процессора (таких как SSE2-команды, которые имеет только процессор Pentium® 4 и последующие), компилятору можно дать указание создать дополнительный общий путь в коде, который будет выполняться на предыдущих процессорах. Такая сборка позволит получить максимальную производительность на новых процессорах, но в то же время будет работать и на уста-
Оптимизирующие компиляторы 37 ревших. В табл. 3.2 перечислены специфичные для конкретных процессоров флаги командной строки для компиляторов Intel. Таблица 3.2. Специфичные для процессоров флаги оптимизации компиляторов Intel Код будет работать только на этом процессоре и более новых Процессор Pentium® III с SSE-командами Процессор Pentium® 4 с SSE2-командами Процессор Pentium® M с 55Е2-командами Процессор Pentium® 4 с ЗБЕЗ-командами Код будет работать на этом процессоре и на любом другом (благодаря общему пути в коде) Процессор Pentium® III с SSE-командами Процессор Pentium® 4 с 58Е2-командами Процессор Pentium® M с 85Е2-командами Процессор Pentium® 4 с SSES-командами Флаг для Windows/ Linux -QxK/-xK -QxN/-xN -QxB / -xB -QxP / -xP Флаг для Windows/ Linux -QaxK/-axK -QaxN / -axN -QaxB / -axB -QaxP / -axP При использовании компиляторов Intel в среде разработки Microsoft Visual C++ .NET многие флаги компиляторов доступны через элементы вкладки Optimization окна свойств проекта (как показано на рис. 3.2). Здесь есть выпадающее меню, в котором имеются все расширения команд процессоров Intel. Флаги, которых здесь нет, можно просто добавить на вкладке Command Line. Написание функций для конкретного процессора Иногда может понадобиться оптимизировать функцию путем вставки ассемблерного кода с определенными командами, имеющимися только на конкретных процессорах. Для этого компилятору нужен код для распознавания процессора. Тип процессора может быть определен путем вызова ассемблерной команды CPU ID с единичным значением в регистре ЕАХ (детали о команде CPU ID см. в руководстве «Intel® Architecture Software Developer's Manual, Volume 2: Instruction Set Reference» и в документе «Application Note AP-485 Intel® Processor Identification and the CPUID Instruction»). После выполнения команды CPUID в регистрах будет содержаться информация, идентифицирующая текущий процессор (а также прочая информация о функциональных возможностях и размерах кэшей). Вы сможете задействовать эту информацию в своем приложении для избирательного использования разных функций на разных процессорах.
38 Глава 3 • Инструменты повышения производительности File Edit tfew Eroject guild Qebug look Solution Explorer - Vector ? X Vector.cpp ^ Solution Vector' (1 project) - 3 Vector - lP Vector (si Ref en i3 Source Vectj Й li Header listed £3 Resourcj j0 ReadMrf Configuration: Configuration Manager... | |Q| Configuration Properties General Debugging £3 C/C++ General Ф- Optimization Preprocessor Code Generation Language Precompiled Heade Output Files Browse Information Advanced Command Line 1 D Linker £j Browse Information D Build Events Ql Custom Build Step |< > Optimization Global Optimizations Inline Function Expansion Enable Intrinsic Functions Disabled (/Od) No Default No Floating Point Precision Improvemeni None Favor Size or Speed Omit Frame Pointers Enable Fiber-safe Optimizations Optimize For Processor Optimize For Windows Application Use Intel(R) Processor Extensions Neither No No Blended No None ' ming SIMD Extensions 3 (SSE3) ( /QxP) I J Intel Pentium(R) III and compatible Intel processors (/QxK) R4Intel Pentium(R) 4 and compatible Intel processors (/QxN) JHlntel Pentium(R) M and compatible Intel processors (/QxB) Ы ? x Ready Рис. 3.2. Флаги оптимизации компилятора C++ производства Intel Проще решить эту задачу с помощью механизма диспетчеризации процессоров для компиляторов Intel, автоматически генерирующего эффективный код выявления процессора; этот механизм облегчает написание функций, специфичных для определенного процессора или группы процессоров (не требуя разбираться в деталях, касающихся команды CPUID). Ключевые слова cpu_di spatch и cpu_speci f i с модифицируют объявление функции, что приводит к вызову компилятором специфичной для конкретного процессора функции (как показано в следующем коде). _declspec(cpu_specific(generic)) void fn(void) { // Здесь должен быть код общего назначения _dec 1 s pec (cpu_specific( Pent ium_4)) void fn(void)
Типы программных профилировщиков 39 // Здесь должен быть код, специфичный для процессора Pentium 4 } declspec(cpu_dispatch(generic, Pentium_4)) void fn(void) { // Пустое тело функции. Ничего здесь не пишите. // Компилятор поместит сюда код диспетчеризация процессоров. } Другие варианты оптимизации компиляторов В дополнение к специфичным для конкретных процессоров вариантам оптимизации компиляторы C++ и Fortran производства Intel поддерживают многие дополнительные варианты (как показано в табл. 3.3). В последующих главах имеются и другие примеры. Полный список вариантов оптимизации ищите в документации на соответствующий компилятор [16]. Таблица 3.3. Другие флаги оптимизации компиляторов Intel Возможности оптимизации Оптимизация Улучшенные варианты оптимизации Развертывание цикла Варианты оптимизации по профилю Межпроцедурные варианты оптимизации Опция для Windows/ Linux -02/-02 -ОЗ/-ОЗ -Qunroll[n] / -unroll[n] -Qprof-gen / -prof-gen -Qprof-use / -prof-use -Qip/-ip-Qipo/-ipo Описание Выполняются все основные и некоторые улучшенные варианты оптимизации Выполняются все улучшенные варианты оптимизации Циклы развертываются автоматически указанное максимальное количество раз Трехэтапный процесс инструментальной компиляции, инструментального выполнения и компиляции обратной связи Автоматическая оптимизация многих функций в пределах одного или нескольких файлов (или даже целой программы), а не в каждой отдельной функции (для поиска более удачных вариантов оптимизации) Типы программных профилировщиков Для оптимизации программного обеспечения в дополнение к хорошему оптимизирующему компилятору необходим еще и хороший полнофункциональный программный профилировщик. В зависимости от цели вы можете выбирать
40 Глава 3 • Инструменты повышения производительности из двух типов программных профилировщиков — выборочного или инструментального. □ Выборочные профилировщики функционируют путем периодического прерывания работы системы для записи информации о производительности (такой как указатель команд процессора, идентификатор потока, идентификатор процесса и счетчики событий). Собрав необходимый объем данных, вы можете получить точное представление о том, что именно делала программа в сеансе сбора данных. Выборку лучше всего применять тогда, когда можно собрать ровно столько данных, сколько нужно, чтобы получить точное представление, но не так много, чтобы это повлияло на производительность системы. Выборка примерно 1000 значений в секунду позволяет сохранять на низком уровне потери, связанные с выборкой (примерно около 1 %), а точность при этом останется высокой. Два очень часто используемых выборочных профилировщика: монитор производительности производства Microsoft (PERFMON.EXE) из пакета Microsoft Windows NT и более новых операционных систем и анализатор производительности VTune™ производства Intel. □ Инструментальные профилировщики используют для вставки профилирующего кода в приложение либо непосредственно двоичную инструментовку, либо компилятор. Инструментовка напоминает вставку в приложение вызовов функций отсчета времени, однако она обеспечивает еще и сбор дополнительных данных о производительности (таких как дерево вызовов, количество вызовов и затраченное функциями время). Некоторые часто используемые инструментальные профилировщики: профилировщик Visual C++ производства Microsoft, анализатор производительности VTune производства Intel и профилировщик codecov компилятора производства Intel. Монитор производительности производства Microsoft Монитор производительности производства Microsoft (PERFMON.EXE) — это выборочный профилировщик, который использует прерывания таймера операционной системы для своего пробуждения и записи значений программных счетчиков (например, количества операций чтения с дисков, процента процессорного времени и объема свободной памяти), что делает его превосходным средством поиска проблем в системе. Максимальная частота выборки в мониторе производительности составляет один раз в секунду, что делает его инструментом с низким разрешением. Недостатком монитора производительности является то, что он не может указать точный фрагмент кода, являющегося источником событий. На рис. 3.3 показан скриншот монитора производительности.
Анализатор производительности VTune™ 41 jka Performance Console Window Help нгеи D c£ У i HI j.«Jtf|xji Action View Favorites j J 4- -* | 2з|Ш | Й* | Tree J Favorites J Q2 Console Root 3^3 System Мог r+] Ш Performance <L ±1 HialFa)laolal +1хЫ *1в|и1 oi*iifi w^.. ^ ~v*JXJ\~«., Last 0.000 Average Maximum 6.283 Minimum 233.664 Duration 0.000 1:40 Color 1 Scale - 1.000 - 1.000 J Counter % Processor Time Disk Transfers/sec 1 Instance _Total Total I Parent ... 1 Object Processor PhysicalDisk I Computer j \\PICKLES2 \\PICKLES2 T Рис. З.З. Монитор производительности производства Microsoft тм Анализатор производительности VTune Анализатор производительности VTune — это полнофункциональный программный профилировщик, который может анализировать всю систему, приложение или драйвер путем как выборки, так и инструментовки. Ознакомительная версия VTune находится на сайте Intel Software Development Products. Кроме того, полное описание анализатора можно найти в литературе [27]. Выборка Анализатор VTune выполняет выборку в масштабах всей системы. В качестве источника прерываний он может использовать таймер операционной системы, счетчики событий внутри процессора или счетчики других устройств. Когда происходит прерывание, значения счетчика или счетчиков записываются вместе со значением указателя команд (EIP), чтобы можно было установить, какой фрагмент кода вызвал данное событие. Самым популярным счетчиком является Clockticks, поскольку он отслеживает время, что дает возможность увидеть, выполнение каких фрагментов кода занимает максимальное время. Значения могут быть отсортированы или сгруппированы по процессам, потокам, модулям, функциям или Е1Р-адресам.
42 Глава 3 • Инструменты повышения производительности S3 File Е* ***» Activity Configure JMndow Help f? tj iJ ' ^ <5 ^> ' ;jActivity 1 (Sempkng) ~3> » H X | Ф Ч ^3 ^ i ЧУ Ф Ч [Ц ||Я JED Ф -3 fj*0"*5 j? Thread ^ Modute i 5П Hotspot ® Source jj X Gtoupby jj£ Function Functipn 3 Events г int HuffConpfess$(unsigned char * ".unsigned void GetCode$(struct tagHuffCode *,un»gnecj: ► void AppendBits$furwigned char '.unsigned 100 1000 20.00 30.00 40.00 50.00 6000 70.00 80ХЮ 9O00 100 Events Total Dockl^s samptes(2l6j 1.099.00Г Clockticks events(210) 1.868.300.000.00 Clockticks *(210) 42.42 Event ActivitylD Scale Sample After Vakje TotalSamples Durabon(s) RingO Ring3 SiartTime MachineName Proce Й ♦ CJocktcks"210"" 0 60rX000i00^^i7Orxdu"" 3099 368 354 2745 Л Л 8/2005 3:77» FN~Te^^^ ll 4 items, 1 events, 1 ltem(s) selected. Al Process At Thread Sawping Modules - [Sampling Results] j 8пврШщ Hrttpefa - (giwpBui Rewte]} Fcr Help, press Fl Рис. З.4. Выборка счетчика Clockticks для программы HUFF.EXE На рис. 3.4 показан скриншот экрана анализатора VTune, на котором показаны результаты выборки счетчика Clockticks, сгруппированные по функциям. График на рис. 3.4 показывает, что изображенная внизу функция AppendBits расходует больше всего времени (самый длинный столбец), а функции GetCode и HuffCompress являются соответственно второй и третьей по этому показателю. Выясняя, какие функции расходуют большую часть времени, вы узнаете, откуда надо начинать оптимизацию приложения. Анализатор VTune может отслеживать примерно 50-100 процессорных событий (в зависимости от процессора, на котором выполняется приложение). На рис. 3.5 показано окно анализатора VTune с результатами выборки часто используемого события Branch Mispredictions Retired. Здесь видно, что функция GetCode (в самом низу) имеет максимальную выборку этих событий, следовательно — максимальное количество неверно предсказанных переходов. Интересно отметить, что GetCode — не самая затратная по времени функция (как это видно на рис. 3.4). Совместный анализ разных счетчиков событий поможет определить причины, по которым функция выполняется так долго.
Профилировщик codecov компилятора Intel 43 Ogle &ft Sew £c«vity Configure VSfmdow tjelp : /^ (Jj *j> ' C* ! "■ ^ ^ 1] ^ <£> ! ^> ; HJActiv,ty1 (Sampling) "3 ► * и x : о ^ аэ : Vй Si 4 Ш i! О : HI **] I L23 : 3 ****** IP ^ead ф Module j 53 Hotspot © Source Group bjr. |A Function Functign »^____ ► street votdPQueueJrwert^stwcttagHuffrfeeNode I r* HurTComp»ess$(ur«igned chat * %unsjgnec|: void AppendBits${unsigned char ".unsigned void GetCode$(struct tagHuffCode '.unsigned Щ IS«*L_ Total "" Branch Mispredictions Retired sample... 1.00 Branch Mispredictions Retired events(... 18.750.... Branch Mispredictions Retired X(216) 0.03 J±l Even» ' AcrivityD Scate ; Sample After Value TotalSamptes Duration(s) ;RngO Ring3 :SiartTime ;Machine "V TrenchMispredictions Retired~ 21 G~ "б'ооббшСЮОООх 18750" ~ 3428 341 32~ "'339?Т1Л8/2б053:27:46РМ~ KBSMIT items, 1 events, 1 *em(s) selected, Sampling Modules • [Sampling Results] Sampling Hotxpofc - [SampBne Results] ] for the relevant*,): BranchMispredctionsRetire, w 1815:27:36 2005.<tooahnt> (Run 1) The Samplng Cotector is cafibratine its c. Fri Nov 1815:27:46 2005 <localhost> (Run 1) The Sampling O*ectarscoltectirx)sen^ based on the folow^ "fri Nov 1815:27:50 2005 <locarhost> (Run 1) Samplng data was successful ccfccted Fct Help, press Fl Рис. З.5. Выборка события Branch Mispredictions Retired для программы HUFF.EXE Граф вызовов Граф вызовов получают при профилировании приложения путем инструментовки. Он показывает иерархию функций; время, затраченное в функции и в ее потомках; количество вызовов. Информация такого типа наиболее полезна для выявления алгоритмических проблем и как дополнение к анализу выборки. На рис. 3.6 показан скриншот с графом вызовов, полученным при помощи анализатора VTune. Граф вызовов показывает, что функция mai n вызывает функцию Huff Compress, которая в свою очередь вызывает функции GetCode и AppendBi ts. Жирная стрелка, идущая от функции main через функцию HuffCompress к функции GetCode, показывает критический путь (то есть путь по функциям, расходующим основную часть времени). Профилировщик codecov компилятора Intel В комплекте компиляторов C++ и Fortran производства Intel имеется инструмент для проверки кодового охвата, который может также использоваться как
44 Глава 3 • Инструменты повышения производительности Рис. 3.6. Профилирование приложения HUFF.EXE при помощи графа вызовов профилировщик с целью определения, сколько раз выполнялись строки исходного кода.Это инструментальный профилировщик, причем инструментовку обеспечивает компилятор Intel. Для применения профилировщика Codecov необходимо сначала откомпилировать программу с флагом -Qprof_genx компилятора Intel. В результате код инструментовки вставляется в объекты и исполняемый файл. Затем программа запускается, и во время выполнения пишутся файлы, которые содержат счетчики для каждой выполненной строки программы. Затем профилировщик codecov создает HTML-файлы, воспроизводящие исходный код с примечаниями, показывающими, сколько раз выполнялась каждая строка. Этот профилировщик способен предоставлять информацию только о количестве проходов, но не о времени. Тем не менее, количество проходов тоже может быть очень ценным показателем, позволяющим понять поведение исходного кода и использованных алгоритмов. На рис. 3.7 показан результат работы профилировщика codecov для примера кодирования Хаффмана. По числам видно, сколько раз вызывались функции GetCode и AppendBits. Кроме того, вы можете видеть, как часто выполнялись тела циклов в каждой из этих функций. Дополнительная информация о количестве проходов часто обеспечивает такое понимание сути проблемы, которое трудно получить другими методами профилирования.
Выборка или инструментовка 45 шшт, ш н &ш 1 ши-overe 1 blocks 8 0 Intel 1 functions function POueueGetSiz compfn File Edt View Favorites lools Help £J^| Back • ^J [x] я£\ fd j Search Favorites Address ^C:\r^rfJrook\CodeCoverage\C_JP^_BOOK_HUFF_CPP.HTML * ш о s covered functions coverage 100.00 (4/4) 100.00 (8/8) 100.00 (8/8) 100.00 (S/S) 100.00 (4/4) 92.59 (25/27) 100.00 (13/13) 77.78 (7/9) 74.19 (23/31) < . :! Щ funct Apper BuildP Convt . Convt GetCc HuffC PQuei PQuei main | > vjQgo 187) 188) 189) 190) 191) 192) 193) 194) 195) 196) 107) 198) 199) 200) 201) 202) 203) 204) 205) 206) 207) 208) 209) 210) 211) 212) 213) void GetCode (HuffCode *pHuffTable, BYTE symbol, DWORD *pCode, DWORD A 716,628 while (pHuffTable->symbol != symbol) A 9,060,393 (2) pHuffTable++; *pCode ■ pHuffTable->code; A 716,628 *pLen = pHuffTable->size; void AppendBits (BYTE *pData, DWORD BitLoc, DWORD code, DWORD len) Л 716,628 for (i=0; i<len; i++) A 4,037,984 (2) DWORD WhichByte = BitLoc / 3; DWORD WhichBit - 7 - (Bitloc £ 0x07); BYTE Bit = (BYTE)(code » (len - i - 1)) £ 0x01; pData[WhichByte] |- Bit « WhichBit; BitLoc++; A 716,628 214) 215 ) // ■£ My Computer Рис. 3.7. Результат выполнения программы codecov, показывающий количество проходов Профилировщик Microsoft Visual C++ Профилировщик, который поставляется вместе с Microsoft Visual C++/Visual Studio, использует инструментовку. Он выдает текстовый листинг с выполненными функциями и временем, затраченным каждой из них (как показано на рис. 3.8). Профилировщик Microsoft Visual C++ лучше всего использовать для проверки кодового охвата на уровне функций, так как он не позволяет увидеть никаких зависимостей между функциями и обладает высокими накладными расходами. Выборка или инструментовка Выборка и инструментовка используются как взаимно дополняющие методы анализа производительности. Обычно вы начинаете профилирование с выборки
46 Глава 3 • Инструменты повышения производительности \;ykl&e E.d* Jtfew Insert Project guild look Window Help ~1Л*|| Iffel E ~3F 3| ф main С:\dev\huf f\Release\huf f" 1-v Command line at 2001 Jul 27 12:29 Total time: 696.205 millisecond Time outside of functions: 1.052 millisecond Call depth: 11 Total functions: 11 Total hits: 1029014 Function coverage: 30.9*4 Overhead Calculated 8 Overhead Average 8 !Module Statistics for huff exe с:\dev\mars.j pg ±1 Time in module: 695.153 millisecond Percent of time in module: 100.0?i Functions in module: 11 Hits in module. 1029014 Module function coverage: 90.9/J Func Time У. Func+Child Time Hit Count Function 246.933 244.438 193.166 751 541 501 312 200 168 144 35.5 35.2 27.8 1.1 0. 0. 0. 0. 0. 0. 687.402 244.438 193.166 695.153 100 1.541 0 0.501 0 0.745 0 0.200 0 0.379 0 0.144 0 98 35 27. $1>Ъ* in File*2 X Results X SQL Debugging \ Profile /jN j Ready 1 HuffCompress(unsigned char » *,unsigned char *,unsi 510272 GetCode(struct tagHuffCode »,unsigned char.unsignec 510272 AppendBits(unsigned char »,unsigned long,unsigned J 1 _nain (huff .obj) 7189 compfn(void const *,void const *) (huff obj) 511 PQueueInsert(struct tagHuffTreeNode *,struct tagHuf 1 ConvertPQueueToTree(struct tagHuffTreeNode * »,unsi 256 ConvertToTable(struct tagHuffTreeNode »,unsigned lc 1 BuildPQueue(struct tagHuffTreeNode » *,unsigned lor 510 PQueueRemove(struct tagHuffTreeNode *,struct tagHuf ,__ _ _„„_ m Ln1,Col1 READ Рис. З.8. Профилировщик Microsoft Visual C++ (благодаря ее низким накладным расходам и способности к анализу в масштабе всей системы), а затем делаете граф вызовов (если вам требуется дополнительная информация). В табл. 3.4 представлены функциональные возможности и достоинства обоих подходов. Таблица 3.4. Профилирование путем выборки или инструментовки Накладные расходы Профилирование в масштабе системы Выявление неожиданных событий Настройка Выборка Очень низкие, обычно не более 1 % Да. Профилируется все — все приложения, драйверы и функции операционной системы Да. Удается обнаруживать другие программы, которые крадут время системы (такие как входящий факс) Не требуется Инструментовка Могут быть высокими, до 10-500 % Нет. Профилируется только приложение, его дерево вызовов i и DLL Нет. Отслеживаются только приложения и их дерево вызовов Необходимо выполнить автоматическую вставку заглушек сбора данных
Пробы и ошибки, здравый смысл и терпение 47 Собираемые данные Детализация данных Выявление алгоритмических проблем Выборка Счетчики, состояние процессора и состояние операционной системы Ассемблерная команда и строка исходного кода, вызвавшие событие Нет. Возможности ограничены процессами, потоками, модулями, функциями и командами Инструментовка Граф вызовов, иерархия функций, время вызовов, критический путь, количество проходов Проблемные функции и строки Да. Позволяет выяснить, что алгоритм или конкретный путь вызовов в приложении является затратным Пробы и ошибки, здравый смысл и терпение Слишком часто инженеры, занимающиеся оптимизацией, увязают в низкоуровневых проблемах производительности и не уделяют внимания более серьезным проблемам. Задайте себе следующие вопросы: □ Имеют ли смысл значения показателей производительности? Иногда значения показателей производительности могут быть неверными (из-за архитектуры процессора, системы управления энергопотреблением или фоновых задач операционной системы). Подумайте о том, что говорят вам эти числа, что они значат и имеют ли они смысл. Если в программе, которая расходует большой объем оперативной памяти, нет промахов кэша, значит, что-то не так. Сравнивайте полученные результаты с ожидаемыми значениями. □ Каково самое простое решение? Если вам удастся сделать ваши варианты оптимизации простыми и понятными, вы получите удобное в сопровождении и долговечное программное обеспечение. Всегда старайтесь придерживаться простоты — все будут очень счастливы, в том числе процессор, компилятор и даже ваши сослуживцы. Когда варианты оптимизации становятся сложными и трудными в программировании, это значит, что, вероятно, есть и более удачные методы. Отвлекитесь на минутку и подумайте, как сделать более быстрым все приложение, а не только одну функцию. □ Как проверить производительность алгоритма до того, как я его запрограммирую? Тестовые структуры и приложения-прототипы оказывают значительную помощь в понимании проблем производительности. Написанное вами маленькое приложение, выполняющее аналогичный код, может помочь с минимальными затратами опробовать множество различных решений. Терпение в сочетании с тестированием методом проб и ошибок приведут к успешным вариантам оптимизации.
48 Глава 3 • Инструменты повышения производительности □ Самый ли быстрый этот код? Очень важно вовремя остановиться. Такого понятия, как самый быстрый код, не существует, есть только самая быстрая на сегодняшний день реализация, поэтому не застревайте на неделю в попытках сделать одну из функций на пару процентов быстрее. Помните, вы оптимизируете целое приложение, а не единственную функцию. Старайтесь видеть картину в целом. Основные моменты При использовании инструментов повышения производительности помните следующие основные моменты: □ Код отсчета времени, который программист размещает непосредственно в приложении, очень выгоден, поскольку позволяет отслеживать алгоритмические проблемы (а не только проблемы с командами или функциями, за которыми могут следить и другие инструменты профилирования). □ Всегда используйте параметры оптимизации компилятора, чтобы собрать оптимизированное приложение, оснащенное инструментами повышения производительности. Никогда не пытайтесь анализировать производительность неоптимизированной сборки, ее профиль радикально отличается. □ Для достижения максимальной производительности с минимальными усилиями необходимо понимать и использовать все функциональные возможности оптимизирующего компилятора. □ Для выявления проблем производительности программные профилировщики используют выборку или инструментовку. Это взаимодополняющие подходы к анализу производительности.
Горячие точки Узнать, какую часть приложения необходимо оптимизировать — самый важный шаг в процессе оптимизации программного обеспечения. Время, потраченное на оптимизацию нужных частей приложения, обязательно окупится, однако сколько бы времени вы не тратили на оптимизацию других частей, результат будет минимальный или вообще нулевой. Это очень похоже на усиление самого слабого звена в цепи. Усильте самое слабое звено — и цепь станет прочнее, но если вы усилите заведомо сильное звено, ничего не изменится. Если не вдаваться в детали, горячие точки — это самые слабые звенья приложения, поэтому их надо оптимизировать в первую очередь. Горячая точка определяется как область интенсивного выделения тепла или область интенсивной активности. На рис. 4.1 показаны горячие точки на карте температур поверхности Тихого Океана и Моря Кортеса. Национальное агентство по изучению океана и атмосферы (NOAA) использует спутниковые изображения для определения местонахождения горячих точек океана. Горячие точки программного обеспечения обнаруживаются при помощи анализатора производительности и путем проведения теста производительности. Самые длинные столбики на диаграмме, показанной на рис. 4.2, являются горячими точками приложения, местоположение которых установлено при помощи анализатора производительности VTune™. Причины появления горячих и холодных точек Программы выполняются неравномерно. Некоторые части приложения выполняются совсем мало времени, выполнение других, как кажется, длится вечно. Вот три основные причины этого: □ Нечастое выполнение. Некоторые части приложения (такие как код инициализации и код обработки ошибок) выполняются только один раз или не выполняются Л
50 Глава 4 • Горячие точки D«» courtesy of: USDOC/NOAA/NESDIS nceanWatdi IMAGE* Date: 2Q05/»9/2?JD 270 Start tint: 03:00:J0 bTC End tin«: 05:59:59 UTC Projection type: MAPPED Map projection: 0.05 dsg/pixel GEOGRAPHIC Latitude bounds: 21 N-» 52 N Lonyitnile bounds: 14CW-> 104 W Рис. 4.1. Горячие точки в Тихом Океане и Море Кортеса Щ&е £сй Vjew Acbvfcy Configure Й3™**ч Цф Е5Ш ■ Jnlxl в ^12 СЭ> <Я ^> ; jAcnvicyl (Sampling) "3 E> * if X j Ф V ¥ J3 £$ I ^ Ф j] G 1 ? J] В Pf««s ф Т*6*1 Ф "«** ! С Hocspoc £? Source Gfoupbjr. jjj Function Function ?R3iJeue»n^.@^AXPAUlaoHuffTfeerJ( THuffComptes* @@YAHPAPA£PA£K®Z ► ?Q«^CKte.@^A><PAUtac^uffC«ie@<S€P>|| ?Appenc8i«*.@@YAXPAEKKK€e Events Instructions Retired samples(76) Clockticks samples(76) Instructions Retired events(76) Instructions Retired *(76) Clockticks events(76) Clockticks *(76) Clockticks per Instructions Retired (C... Total 7|3цвй1 30.00 I 62.776... 45.16 j 44.350.... 36.14 ] J -*J Instructions Retired 76 Clockticks 76 Clockticks per Instructions Retired (CPI) 76 ActivtylD Scale 0000001OOOOOx 498223 OOOOOOIOOOOOx 1478362 ЮОООООООООООх I ! Sample Aftei Value Total Sample* 'Duration (*) RmgO Ring3 Start Time 336 0078 0.078 8714/20051 20:01 F* j 8/14/2005 1:2*01 PI* I -iJ J4 items, 3 events, 1 item(s) selected. Sampling Modules - {Sampling Results) ) Sampling; Hotspots - {Sampling Results] j~ "f [Sun Aug 14 iaia5S 2Ю5 <lccaliost> (Run 1} Tr«s SampIhgColectoi is caft»a^ Sun Aug 1413:20:01 20CJ5<la*lhc^ (Run 1} The SampSrigCo^ Sun Aug 1413:2*01 2005 <tocah»i> (Run 1) Sampling data was адиж5*и%> collected. Рис. 4.2. Горячие точки приложения, найденные при помощи анализатора производительности VTune™
Больше, чем просто время 51 вовсе. Поскольку эти части выполняются очень малое количество времени по сравнению с остальной частью приложения, они являются «холодными точками», которые не имеет смысла оптимизировать. □ Медленное выполнение. Выполнение сложных в вычислительном плане частей приложения может занимать длительное время. Например, моделирование воды, текущей через дамбу, требует множества вычислений и занимает много времени. Эти части становятся горячими точками только тогда, когда их выполнение занимает значительную долю времени по сравнению с выполнением остальной части приложения. □ Частое выполнение. Многие части приложения выполняются часто. Перерисовка экрана в компьютерной игре или обработка нажатий клавиш в текстовом процессоре — вот только два распространенных примера. Не все часто выполняющиеся части автоматически становятся горячими точками, а только те из них, которые расходуют значительную долю времени по сравнению с остальной частью приложения. Если вы знаете, что именно привело к появлению горячей точки (медленному выполнению кода, частому его выполнению или тому и другому), это поможет вам определить, какие варианты оптимизации будут работать лучше всего. Больше, чем просто время Горячие точки — это области любой чрезмерной активности, а не только интенсивного расходования времени выполнения. Помимо расходования времени, горячие точки могут обнаружиться там, где есть много промахов кэша, отсутствующих страниц памяти или неправильных предсказаний переходов. Поскольку оптимизация программного обеспечения, в том смысле как она воспринимается пользователем, обычно направлена на повышение производительности, почти всегда приоритетом становится время. Однако существуют исключения. Например, для драйвера устройства иногда важнее ценой незначительной потери времени ограничить количество используемых строк кэша, чтобы сохранить состояние кэша для прерванного приложения. На рис. 4.3 показаны горячие точки, касающиеся времени, неправильных предсказаний переходов и промахов кэша уровня 1 (для одного и того же приложения). Как видите, горячие точки некоторых событий находятся в разных функциях. Например, функция Huff Compress расходует меньше всего времени, но имеет больше всего неправильных предсказаний переходов и с большим отрывом опережает остальных по количеству промахов кэша уровня 1. Информация такого рода помогает Сосредоточиться на оптимизации определенных типов операций внутри функций (таких как обращения к памяти или переходы).
52 Глава 4 • Горячие точки Wfflj[jmLIJJl»JAM.UJJAIl!AblHUI.llJll.im Ц*; File Edit #ew Activity Configure window Help шшвшшшшм ^jS^Ci^ ^8 S3 4> ^ -^ jActivityl (SamplmgJ "30 > » li X \ О Ч i ¥ tS-^l^ Ф 3 il Й 1 :ч D fj^ess Jp Thread #1 Modute j О Hotspot ® Source t<MKjft . Clockticks samples(78) Mispredicted Branches Retired samp). 1st Level Cache Load Misses Retired... Instructions Retired samples(78) Clockticks events(78) Clockticks *(78) Mispredicted Branches Retired event... Mispredicted Branches Retired 2(78) 1st Level Cache Load Misses Retired 1 st Level Cache Load Misses Retired . Instructions Retired events(78) Instructions Retired *(78) 1 st Level Cache Load Miss Performan. Branch Mispredict Performance Impa... J I Total "26.00 19.00 2.00 31.00 89,8... 49.06 44.6... 32.63- 0.10 Event Clockticks Mispredicted Branches Retired 1 st Level Cache Load Misses Retired Activity ID Scale 78 "6.000001 OTJddOx " 78 OOOOIOOOOOOOx 78 O.OOIOOOOOOOOx j Sample After Value Total Samples 3454562 " 198""" 9479 173 0.109 4304 250 0.062 T ! Duration (s) ReigO Ring3 Start T«*l 0 109 4Э 149 8/14/2cTj /20 720 „j IT 8/14/20 8/14/20» 1 rtem(s) selected. Sampling Modulco (Sampling Rooultc] j Sampling Hotepotc [Sompllng Rcsurto] j Ш {General ISun Aug 1413:25 07 20TJ5 <loMlhc(st> (Run 2) Tte ISun Aug 1413:2508 2035 <l«*lrrot>(Rw 2) The 5апр1ггщ^ jSun Aug 1413:25:09 2005 <locelhost> (Run 2} Sampfing data was successful collected. Sun Aug 1413:25:09 2005 <localhost> (Run 3) The Sampling Collector is cajrbrating *» cofection parameters for the following eventfs): Instructions Retired Sun Aug 1413:2510 2005 <tocalhost> (Run 3) The Sampling Collector is collecting samples based on the following eventfs): Instructions Retired jSun Aug 1413:25:11 2005 <locarhost> (Run 3) Sampling data was successful coflected For Help, press Fl Рис. 4.З. Горячие точки, связанные с промахами кэша, неправильным предсказанием переходов и временем Равномерное выполнение без горячих точек Когда выборочное профилирование не показывает никаких четких горячих точек (как на рис. 4.4), возникает интересная проблема. К сожалению, такой результат не означает, что приложение полностью оптимизировано. Он означает только, что попытка обнаружить горячие точки при помощи метода, основанного на периодических выборках данных, не дала результата, который позволил бы определить начальную точку оптимизации. Часто программа с очень плоским профилем имеет большие объемы однотипного кода или кода с макросами. В таких случаях вызов каждого макроса становится отдельным кодом, так что оптимизация макроса может дать выигрыш в производительности для всего приложения. Равномерный профиль — это тот случай, когда лучше всего может помочь компиляторная оптимизация. В этих случаях важно получить хорошую производительность от каждого фрагмента кода, и обычно сделать это при помощи компилятора проще, чем вручную оптимизировать каждый фрагмент. У программы на том или ином уровне всегда есть горячая точка; просто чтобы ее найти, надо искать в разных местах или при помощи разных инструментов. Иногда горячие точки появляются только на уровне функций или приложений,
Основные моменты 53 ядшвшшшвШ SB 3i d? "J с* ^ и ta><5 -^ IS N и швшншн ■JActivityl (Samplng) -.'; [> SJ ! Ъ <*l & lie Ш Щ 1 13 1 S^ocess JJJ Thread 01 Modute ; С Hotspot ©Source Group by: jj£ Function func44 fund fund 7 func30 func20 func92 funclG func26 ► func52 rffiffl^^H^^MIL- Events : j Evert □ Instructions R • -j- Cfocktcks jlOl Hems, 3 events, 1 *em(s) selected. Sampling Modules • (Samplng ftssufc) j SmnvIm — ■ ■ etired •firprl 1 nstn ictinn f Hetopett -|S [{General [for Help, press Fl i \ I : i 1 1 : 20.00 3000 4000 5000 6000 70.00 80.00 их"': Ф ч ■■■■Ш1 ^ifilxJl €■ ; i X. II ИИ 90.00 100 v Event* Total Instruction* Retired samples{112J 105.00 Clocktickssamples(112) 74.00 Instructions Retired events(112) 178,50.. Instruction: Retired Z[\ 12) 0.89 j Clockticksevents(112) 125,80... || ClockticksX{112) 1.25 Cycles per Retired Instruction-CR(112) 0.70 I ActivrylD Scale Sample After Value ; Total Samples Duration (s) RingO Ring3 Start Time *1и< || 112 0.0000001 OOOOx 1700000 112 0 0000001 OOOOx 1700000 ppi и? innnnnnnnnnnnnx n -~- aJ 12033 Б.28 88 11945 8/14/2005 221:28 PM ICE Б244 6.28 185 6059 8/14/2005 2:21:28 PM KE n nm n n ~ ... j | AlProcess AI Thread 1 Module ■ишшпишапег шпияяниш щ Рис. 4.4. Выборочное профилирование не показало серьезных горячих точек что может указывать на алгоритмические или структурные проблемы. В этих случаях для выявления горячих точек можно проанализировать граф вызовов. На рис. 4.5 изображен граф вызовов, который показывает, что горячей точкой являются функция Huff Compress и ее дочки. В таких случаях необходимо подумать о том, как улучшить алгоритм (а не заниматься оптимизацией мелких фрагментов кода внутри отдельных функций). Другим приемом, который может помочь в поиске точек оптимизации, является выборка событий памяти. Промахи кэша означают, что код использует основную память вместо более быстрой кэш-памяти (что не оптимально). Такой анализ может указать на структуры данных и буферы памяти, обращение к которым осуществляется неэффективно. Подробное обсуждение оптимизации памяти имеется в главе 8. Основные моменты При поиске горячих точек помните следующие рекомендации: □ Горячие точки — это те области приложения, в которых наблюдается повышенная активность.
54 Глава 4 • Горячие точки Hefc go* tfew Activity Configure Window Це1р Jj9J*J {±&UJ && ЧЙ «s ■tjU'^^-^ JActivityl (Cal Graph) *^ Module (104) ' Thread (104) Function (104) advapi32.d.l-T... advapi32.dll Thread_0(B9 Class (104) Calls (104) Self Time (104) Total Time (104) Callers (104) Callees (104) Module Л 1.438,337 118,784 8 3 СЦ p ч ч •$♦ J| : ^& »' g 68* Д Show top JAuTcTg * | Recalculate j Highlight ,& jWrlteFte !T Graph [CalШ1 33 nodes, 32 edges; (33 and 32 shown; 0 and 0 select Instrumentation Results iSunAup 1413:45:53 2005 Updating cal graph database... Рис. 4.5. Поиск горячих точек путем анализа графа вызовов Q Повышенная активность обычно подразумевает расход процессорного времени, но это определение может включать все, что угодно (например, неправильное предсказание переходов или промахи кэша). □ Горячие точки указывают на те области, с которых нужно начинать оптимизацию. □ Положение горячих точек можно установить при помощи выборочного или инструментального профилировщика (либо при помощи обоих).
Архитектура процессоров Знание основ архитектуры процессоров может дать некоторые идеи относительно того, какие варианты оптимизации и как могли бы сработать. Понимание этих ключевых архитектурных концепций (которые реализованы в большинстве процессоров) дает прочную базу для оптимизации программного обеспечения. Кроме того, знание конкретных возможностей отдельных архитектур может помочь решить, как оптимизировать ваше приложение для процессоров Intel® Pentium® 4, Pentium M и других, которые придут им на смену. Процессор Intel® Pentium® 4 содержит приблизительно 125 миллионов транзисторов, а процессор Pentium M — около 140 миллионов, хотя оба они кажутся маленькими по сравнению с двухъядерным процессором Pentium Extreme Edition 840, содержащим около 230 миллионов транзисторов. Попытку понять, что делает каждый транзистор (даже если бы это было возможно), вряд ли можно назвать эффективным подходом, позволяющим разобраться в работе процессора. Подобно многим большим программным проектам, архитектура процессора делится на функциональные блоки (для упрощения понимания). Такое разделенное представление помогает как при разработке процессора, так и при изучении его работы. Когда появился процессор Pentium, он был первым процессором архитектуры Intel IA-32, способным выполнять несколько команд за один такт. Он делал это при помощи двух исполнительных устройств и мог отправить по одной команде на каждое исполнительное устройство в течение одного такта. С появлением в 1995 году процессора Pentium Pro команды перестали выполняться в том порядке, в котором они записаны в программе. С того времени длина процессорных конвейеров и количество исполнительных устройств увеличились. Все эти факторы привели к тому, что современное поколение процессоров архитектуры Intel IA-32 выполняет очень большое количество команд одновременно за один такт. Учитывая, что все эти команды находятся в процессоре одновременно, очень важно разбираться как в функциональных блоках процессора, так и во взаимодействии
56 Глава 5 • Архитектура процессоров между ними. С учетом этого можно сказать, что целью оптимизации программного обеспечения является выдача процессору команд, которые позволят его функциональным блокам выполнить максимальный объем работы за заданное время. Знание функциональных блоков, их взаимодействия и использование некоторых рекомендаций — вот основа эффективной оптимизации приложения. Во всей этой книге все рекомендации направлены на то, чтобы процессор на каждом такте имел несколько доступных для выполнения команд. Функциональные блоки Процессоры Pentium 4 и Pentium M выполняют команды в три этапа. На первом этапе препроцессор выбирает команды из памяти, декодирует их в том порядке, в котором они записаны в программе, и посылает декодированные команды на исполнительное ядро. На втором этапе в исполнительном ядре среди декодированных команд производится поиск команд, готовых к выполнению. Затем они выполняются в максимально быстром порядке. Этот порядок выполнения может отличаться от того порядка, в котором команды записаны в программе. На третьем этапе выполненные команды удаляются из исполнительного устройства, после чего их выполнение завершается (в соответствии с исходным порядком следования в программе). На рис. 5.1 показана упрощенная блок-схема процессора Pentium 4. На этой схеме блоки выборки/декодирования, кэша трасс и предсказания переходов относятся к первому этапу, а этапы выполнения и удаления представлены одиночными блоками; остальные блоки представляют собой кэши памяти. Блок-схема процессора Pentium M, показанная на рис. 5.2, очень похожа на архитектуру Pentium 4, но есть одно существенное различие — процессор Pentium 4 имеет буфер кэша трасс, который передает команды на этап выполнения процессора Pentium 4 без их повторного декодирования. Этот кэш трасс снижает объем работы, которую нужно выполнять декодеру команд, чтобы снабжать командами исполнительное устройство. Для обоих процессоров варианты программной оптимизации состоят в том, чтобы этапы выполнения и удаления были задействованы на каждом такте. Кроме того, чтобы команды эффективно поступали на этап выполнения, для процессора Pentium M важнее обеспечить оптимальную работу на этапе декодирования команд, а для процессора Pentium 4 — оптимальное функционирование кэша трасс. Разные этапы выполнения проще понять, если сравнивать их с автомобильным рестораном быстрого питания. Два чизбургера, пожалуйста! В автомобильных ресторанах быстрого питания обслуживание происходит в три этапа: приемка заказа, приготовление и выдача.
Функциональные блоки 57 Системная шина т Модуль шины I Кэш уровня 3 (необязателен, только на серверных продуктах) i Сильно загруженный путь Менее загруженный путь Кэш уровня 2 (ассоциативный с восемью входами) Кэш данных уровня 1 (ассоциативный с восемью входами) i Выборка/ Декодирование i * -► Кэш трасс (память микрокода) i Блок предсказания перех одов —► Выполнение (ядро внеочередного выполнения) —> Удаление А Обновление истории переходов ^ Рис. 5.1. Упрощенная блок-схема процессора Pentium 4 Этап приемки Люди подъезжают к микрофону и говорят кассиру, что им нужно. Кассир разбивает заказ на отдельные позиции и передает список поварам. Для сокращения времени ожидания ресторан может заранее приготовить некоторые позиции (чтобы они были уже готовы, когда поступит заказ). Этап приготовления Позиции передаются на этап приготовления, где несколько поваров (каждый со своей плитой и своей специализацией) начинают готовить еду, заказы на которую им передал менеджер. Некоторые повара готовят только жареное (вроде картофеля фри или жареного лука), другие делают бургеры. Когда приготовление закончено, еду помещают в зону ожидания, где она дожидается выдачи клиентам. На этом этапе есть кто-то, кто организует поставку сырых продуктов. Сырые продукты могут храниться в одном из трех мест: в зоне приготовления еды (готовые к использованию), в холодильнике, расположенном подсобном помещении, и наконец, на почти недоступном складе или даже у регионального поставщика.
58 Глава 5 • Архитектура процессоров Системная шина I Модуль шины I -► Сильно загруженный путь ->► Менее загруженный путь Кэш уровня 2 (ассоциативный с восемью входами) Кэш данных уровня 1 (ассоциативный с восемью входами) Кэш команд уровня 1 (ассоциативный с восемью входами) Выборка/ Декодирование т V Выполнение (ядро внеочередного выполнения) Удаление Блок предсказания переходов Обновление истории переходов Рис. 5.2. Упрощенная блок-схема процессора Pentium M Чем дальше находятся продукты от зоны приготовления, тем больше времени занимает их доставка. Например, продукты из холодильника можно достать всего за минуту, в то время как доставка заказа со склада может занять несколько дней. Поэтому для ресторана очень важно держать продукты под рукой и готовыми к использованию. Этап выдачи Человек, занятый выдачей заказов, должен собрать все позиции заказа воедино и выдать их именно тому, кто сделал заказ. Поскольку местом выдачи является окно, к которому подъезжает машина, то нужный клиент определяется по порядку появления машин. Проблемы Обычно эти три этапа обслуживания в ресторанах быстрого питания работают эффективно, но иногда что-то может пойти не так, как обычно. □ Слишком много клиентов сразу. Желательно, чтобы клиенты прибывали и делали заказы с такой скоростью, чтобы все повара были заняты, но не так быстро, чтобы система захлебнулась. Если что-то вызывает всплеск заказов (вроде окончания матча неподалеку), то этап приемки заказов оказывается перегруженным, и клиентам приходится дольше ждать.
Функциональные блоки 59 □ Слишком мало клиентов. Во время спада в заказах служащие сидят без дела, понапрасну тратя время и деньги. В идеале ресторану нужен равномерный и постоянный поток клиентов. □ Клиенты заказывают только по одной позиции. Ресторан рассчитан на то, что от каждой машины заказывается несколько позиций, поскольку один заказ нескольких позиций принять быстрее, чем много заказов одной позиции. Накладные расходы на заказ четырех позиций такие же, как и на заказ одной позиции. Повара выстраивают рядами под тепловыми лампами чизбургеры и картошку фри с таким расчетом, что другие клиенты их тоже закажут. Если каждый клиент из очереди заказывает только одну позицию, то эффективность процесса заказов падает, и повара не могут определить, что надо готовить для следующего заказа. Такая ситуация вызывает замедление и на остальных этапах процесса. □ Одна и та же позиция заказывается многократно. Если вдруг каждый клиент начнет заказывать лук колечками, то те, кто готовит бургеры, будут сидеть без дела, а повар, специализирующийся на луке, окажется заваленным заказами. Производственный процесс ресторана приспособлен к типичному распределению позиций заказов. Когда подъезжающие клиенты делают одновременно слишком много заказов одной позиции, то некоторые ресурсы, например гриль или микроволновая печь, оказываются перегруженными (не могут справиться с всплесками спроса), что приводит к увеличению времени ожидания клиентов. □ Кое-что приходится выбрасывать. Для уменьшения времени ожидания клиентов ресторан может приготовить некоторые позиции заранее, рассчитывая, что все они (или большинство) будут проданы. Чаще всего это отлично работает — изделия свежие и выдаются очень быстро. Когда продукт перестает быть свежим, его необходимо выбросить, что приводит к потере денег и еды (но не обязательно времени). □ Скачущая очередь. Если клиент покидает очередь, влезает без очереди или каким-либо образом меняет свое положение в очереди, тогда заказы могут попасть не тому, кому они предназначены. Порядок следования машин не важен до момента заказа и после получения заказа, он важен только во время ожидания. Теперь посмотрим, какое отношение знание механизма работы ресторана быстрого питания имеет к этапам выполнения команд процессоров Pentium 4 и Pentium M. Выборка и декодирование команд На первом этапе команды поступают в процессор и разбиваются на более мелкие подкоманды, называемые микрооперациями, или (xOps (произносится как мю-опс). Многие команды генерируют только одну микрооперацию, но некоторые команды могут генерировать несколько микроопераций.
60 Глава 5 • Архитектура процессоров Когда процессор Pentium M декодирует команды в микрооперации, он помещает микрооперации непосредственно в исполнительное ядро, где они сохраняются в структуре под названием станция резервирования (Reservation Station, RS). Используя три декодера команд, Pentium M может декодировать до трех команд за такт и выдать до шести микроопераций за такт. Декодеры организованы таким образом, что декодер 1 может декодировать любую команду, которая генерирует от одной до четырех микроопераций, а декодеры 2 и 3 могут декодировать только команды, генерирующие одну микрооперацию. Команды декодируются из 16-байтного буфера, выровненного по границе 16 байт. Поэтому лучше всего декодирование выполняется тогда, когда размер и выравнивание команд позволяют им декодироваться из этого 16-байтного буфера и когда последняя декодируемая команда заканчивается в последнем байте 16-байтного буфера. Процессор Pentium 4 помещает декодированные микрооперации в буфер кэша трасс. Кэш трасс затем отправляет микрооперации в исполнительное ядро. Кэш трасс предотвращает повторное декодирование одних и тех же команд. Он комбинирует микрооперации последовательности команд в последовательность микроопераций, называемую трассой. При повторном выполнении этой команды кэш трасс передает микрооперации этой команды непосредственно в пул команд (вместо того чтобы декодировать команду). Процессор Pentium 4 декодирует только одну команду за такт и может генерировать от одной до четырех микроопераций. Кэш трасс избавляет процессор Pentium 4 от необходимости иметь столько же декодеров, сколько их в Pentium M. Кэш трасс может выдать на исполнительное ядро три микрооперации за такт. В обеих архитектурах на первом этапе команды обрабатываются в том же порядке, в котором они хранятся в памяти. Однако переходы являются исключением. Переход может потребовать от процессора выполнения команд, не соответствующих порядку их хранения в памяти. Однако процессору необходимо знать, какие команды выбирать и декодировать далее. Эту проблему иллюстрирует рис. 5.3. Какую команду должен декодировать процессор после декодирования команды 2? Команду 3 или команду 5? test jne mov jmp mov add eax, eax $B1$4 DWORD PTR $B1$5 DWORD PTR ecx, 4 _inp[ecx+20000]. -1 jnp[ecx+20000]. 8 Рис. 5.З. Что происходит, когда встречаются переходы? Для примера рассмотрим процессор с пятью ступенями конвейера, где выборка команды — это первая ступень, а фактический результат команды перехода не известен до пятой ступени. Если бы этап выборки команды был от-
Функциональные блоки 61 ложен до того момента, когда завершится переход на этапе выполнения, то на каждом переходе было бы потеряно пять тактов (время ожидания перехода на этапе выборки, перед тем как направить команду процессору для выполнения), поскольку этап декодирования команды все это время не выполняется. Во избежание потерь времени при ожидании переходов на первом этапе делается предположение о том, какая команда будет следующей, и незамедлительно начинается ее декодирование. Эта функциональная возможность называется пред- сказанием переходов. Когда сделанное предположение оправдывается, все работает гладко, и время не теряется. Однако при неверном предположении процессору приходится останавливать выполнение неверно предсказанных команд, удалять их, выбирать правильные команды и затем начинать их декодирование и выполнение. Вся эта дополнительная работа несколько снижает производительность, добавляя обычно от двух до десяти тактов. Однако реально на снижении производительности сказывается время, потраченное впустую на выполнение неверных команд вместо верных. Первый этап аналогичен этапу приемки заказов в ресторане быстрого питания. Точно так же, как ресторан пытается предусмотреть будущие заказы и способен избавиться от незаказанной еды, каждый процессор пытается предсказать будущие команды и может удалить частично выполненные команды. Для того чтобы делать обоснованные предсказания, процессор использует историю переходов и некоторые основные правила. Правила продуманы таким образом, чтобы предсказывать большинство переходов. Для достижения наилучшей производительности необходимо избегать (или сократить количество) переходов, которые нельзя точно предсказать. Эта тема обсуждается в главе 7. Выполнение команд На втором этапе выполняются команды (а фактически — микрооперации). В архитектуре Pentium 4 микрооперации поступают в исполнительное ядро, после чего переправляются в порты выполнения, которые связаны с одним из семи исполнительных устройств (как показано на рис. 5.4). Каждое исполнительное устройство выполняет определенный тип команд. Три устройства делают целочисленные вычисления, два — вычисления с плавающей точкой. Одно устройство загружает из памяти, другое — сохраняет в памяти. На каждом такте планировщик сканирует очередь исполнительного устройства и посылает в доступное исполнительное устройство на выполнение те микрооперации, которые полагаются готовыми. Команды полагаются готовыми в зависимости от времени, потраченного процессором на выполнение микроопераций, от которых зависят данные команды. Если окажется, что операнды микрооперации не готовы, то команда выполняется с неверным значением операнда. Затем это неверное значение операнда замечается, и команда посылается назад в порт для повторного выполнения тогда, когда будут известны все ее аргументы. Это называется повтором; о том, как с этим бороться, рассказывается в главе 8.
62 Глава 5 • Архитектура процессоров Порт выполнения О АЛУО ADD, LOGIC, BRANCH MOV-операции с плавающей точкой FP MOVES, FXCH Порт выполнения 1 АЛУ1 ADD Операции с целыми Сдвиг, Циклический сдвиг ЕХЕ-операции с плавающей точкой FP, SSE, SSE2 I Порт выполнения 2 1ь W Операции загрузки Все загрузки Порт выполнения 3 w W Операции записи запись по адресу Рис. 5.4. Второй этап — выполнение команд в процессоре Pentium 4 Этапы выполнения в архитектурах Pentium M и Pentium 4 различны. Рис. 5.5 иллюстрирует этап выполнения в архитектуре Pentium M. Когда микрооперации в процессоре Pentium M поступают в исполнительное ядро, они попадают в станцию резервирования (RS). Затем планировщик исполнительного устройства ищет в станции резервирования для выполнения на доступном исполнительном устройстве те микрооперации, операнды которых готовы. В процессоре Pentium M микрооперация считается готовой, когда все ее операнды выполнены. Pentium M имеет шесть исполнительных устройств: устройство загрузки, устройство записи, устройство для выполнения команд с плавающей точкой, устройство для выполнения SSE- и 85Е2-команд, два целочисленных устройства. Для более тщательного рассмотрения команд и их готовности обсудим следующий фрагмент кода: у = m * х + b
Функциональные блоки 63 Станция резервирования w W ^ АЛУ ADD, LOGIC, BRANCH АЛУ ADD, LOGIC Операции с плавающей точкой FP, SSE, SSE2 Операции загрузки Все загрузки Операции записи Запись по адресу Рис. 5.5. Упрощенная схема исполнительного ядра процессора Pentium M Микрооперации могут выглядеть так: Load rO = m Load rl = х Execute г2 = гО * rl Load r3 = b Execute г4 = гЗ + г2 Store у = г4 Для простоты предположим, что выполнение всех команд занимает один такт. На первом такте можно выполнить только загрузку переменной т, поскольку имеется только одно устройство загрузки; ни одна другая команда не может выполняться, поскольку аргументы команд еще не известны. На втором такте можно произвести загрузку переменной х, но операцию умножения начать еще нельзя. На третьем такте можно одновременно выполнить операцию умножения и третью загрузку (поскольку аргументы для операции умножения загружены и имеются доступные отдельные порты для загрузки из памяти и перемножения переменных). На четвертом такте происходит операция сложения, а на пятом — операция записи в память. В реальности эта последовательность, несомненно, потребовала бы больше, чем пять тактов (из-за задержек в памяти и скорости выполнения команд), но идея состоит в том, что аргументы должны быть готовы до того, как сможет произойти операция. Термин зависимость по данным описывает ситуацию, когда некая операция зависит от результата предыдущей операции. В данном примере сложение гЗ и г2 зависит от загрузки переменной b и от умножения регистров гО и rl. В свою очередь умножение гО и rl зависит от загрузки переменных тих. Зависимости по данным ограничивают производительность на этапе выполнения, поскольку исключают возможность внеочередного и параллельного выполнения.
64 Глава 5 • Архитектура процессоров Те же проблемы ограничивают производительность и на этапе приготовления в ресторане. Повара должны ждать готовности бургера перед тем, как добавить к нему салат и помидоры. Некоторые операции (такие как приготовление прожаренного бургера) занимают больше времени, чем остальные (например, добавление пикулей). Кроме того, операции откладываются, если продуктов нет под рукой и их необходимо доставить из холодильника, находящегося в подсобном помещении. Если одновременно поступает слишком много заказов на молочные коктейли, то у человека, который их готовит, получается затор, в то время как другие работники ничего не делают. Работа на этапе выполнения идет эффективно, когда: □ приложению нужно выполнять смесь разнородных команд, так что все исполнительные устройства могут использоваться одновременно и иметь постоянную загрузку; □ аргументы без задержек поступают из регистров или кэша данных; □ отсутствуют значительные зависимости по данным, что обеспечивает готовность операций к выполнению. Удаление После выполнения микрооперации она помечается для удаления. На этапе удаления производится поиск команд, у которых все составляющие их микрооперации помечены для удаления, а все предшествующие команды либо удалены, либо помечены для удаления. Этап удаления завершает выполнение команды. На этом этапе обновляется состояние машины, остальная часть процессора уведомляется о некорректном предсказании перехода, обновляется буфер истории предсказания переходов, при необходимости выполняется запись в память, генерируются исключения, такие как деление на нуль, после чего микрооперации, составляющие команду, окончательно удаляются из процессора. Команды всегда удаляются в том же порядке, в котором они появлялись на первом этапе. В архитектуре процессоров Pentium M и Pentium 4 можно удалить до трех команд за такт. Единственным реальным ограничением эффективности этапа удаления является количество завершенных микроопераций, доступных для удаления. Поэтому если на этапе выполнения работа идет эффективно, и на каждом такте завершается несколько микроопераций, то на этапе удаления имеется постоянная загрузка. Не следует пытаться оптимизировать этап удаления. Вместо этого лучше сосредоточиться на обеспечении бесперебойной работы на первых двух этапах (для сохранения постоянной загрузки на этапе удаления). В ресторане аналогичную функцию выполняет тот, кто выдает заказы. Он выдает еду в том же порядке, в котором она заказывалась, и загружен этот человек работой только пока на всех предыдущих этапах продолжается бесперебойная работа.
Регистры и память 65 Регистры и память Ожидание доступа к памяти часто является самой распространенной причиной, по которой приложения выполняются медленно. Это такая же задержка, как в ресторане, когда у поваров кончаются сырые продукты и им приходится тратить время на доставку со склада новых. Микрооперации можно выполнять только тогда, когда их аргументы готовы, а их неготовность может быть вызвана только двумя причинами: зависимостью по данным или задержками памяти. Архитектура процессора обычно представляет собой набор регистров, чтобы хранить в них часто используемые значения (что позволяет сократить количество обращений к памяти). Это похоже на то, как повар держит под рукой наиболее востребованные продуты: кетчуп, горчицу и пикули. Архитектура IA-32 имеет восемь регистров общего назначения, восемь регистров для операций с плавающей точкой или ММХ-команд (называемых обычно регистрами Х87) и восемь регистров ХММ для SSE-, SSE2- и ЭБЕЗ-команд. Команды расширения Intel EM64T предоставляют дополнительно восемь регистров общего назначения и восемь регистров ХММ. При помощи этих дополнительных регистров компилятор или программист может держать больше значений «под рукой» (а не в памяти), что сокращает количество обращений к памяти. Для иллюстрации этой возможности на веб-сайте данной книги размещена программа-пример EXTRAREGS.C. При компиляции для команд расширения ЕМ64Т программа выполняется на 20 % быстрее, чем при компиляции для команд архитектуры IA-32. Память представляет собой серьезную проблему, поскольку процессоры (повара) гораздо быстрее выполняют код, чем обращения к памяти (которые аналогичны доставке продуктов из холодильника или со склада). Так же как положить пикули на бургер гораздо быстрее, чем достать их со склада, так и процессор может выполнять команды гораздо быстрее, чем обращаться к памяти. Однако в процессорной архитектуре кое-что для этого предусмотрено. Пока процессор ждет доступа к памяти, он может заниматься другими вещами, например, декодированием команд, выполнением готовых микроопераций и удалением команд. К сожалению, может получиться и так, что процессор закончит выполнение всего, что может, но все равно ему придется ждать доступа к памяти. С целью сокращения времени обращения к памяти для хранения часто используемых данных применяются очень быстрые области памяти небольшого размера, называемые кэшами. На рис. 5.6 показаны типичные области памяти, используемые процессором Pentium 4. Архитектура памяти процессора Pentium M выглядит очень похоже, только в ней нет кэша трасс и кэша уровня 3. Чем память дальше от ЦПУ, тем она медленней и тем больше ее объем. Поэтому основная память — самая большая и самая медленная область хранения, а регистры — самая маленькая и самая быстрая. Архитектура памяти организована именно таким образом (с основной памятью и кэшами) для снижения стоимости. Компьютер, имеющий только кэш-память, был бы чрезвычайно дорогим, не давая при этом никаких гарантий выигрыша в производительности. Однако все не так плохо — вы можете писать программы с учетом представленной здесь архитектуры памяти (см. главу 8).
66 Глава 5 • Архитектура процессоров 16 KB/32 KB 512 KB/1 MB/2 MB 1 MB/2MB/4MB 512 MB/1 GB/ 2 GB /4 GB ЦПУ Регистры t Кэш данных уровня 1 t Кэш уровня 2 Кэш грасс (12К микроопераций) i к I Кэш уровня 3 (необязательный) А Основная память Рис. 5.6. Упрощенная схема архитектуры памяти компьютера с процессором Pentium 4 Основные моменты Для эффективного использования архитектуры процессора запомните следующие основные моменты: □ Процессоры ограничены зависимостями по данным и быстродействием команд. □ Хорошая смесь команд позволяет поддерживать постоянную загрузку всех исполнительных устройств. □ Ожидание доступа к памяти при отсутствии другой работы — вот самая частая причина медленного выполнения приложений. □ Целью программной оптимизации является обеспечение процессора большим количеством готовых команд, хорошей смесью команд и предсказуемыми переходами.
Проблемы производительности
Алгоритмы Выбор подходящего алгоритма — это важнейший фактор, определяющий, медленной или быстрой будет программа. Хороший алгоритм решает задачу быстро и эффективно, тогда как плохой алгоритм (вне зависимости от того, насколько хорошо он реализован и настроен) никогда не будет столь же быстрым. Алгоритмы для решения практически любой задачи можно отыскать в различных источниках, таких как Интернет, книги и профессиональные журналы, можно также использовать опыт коллег по работе. Выбор наилучшего для вашей задачи алгоритма — довольно непростое дело. Вычислительная сложность, требования к памяти, зависимости по данным и команды, используемые в реализации алгоритма — все это играет свою роль в том, каким будет алгоритм, хорошим или плохим. Некоторые затраты времени на эксперименты с различными алгоритмами на предварительном этапе зачастую позволяют сэкономить его впоследствии. Вычислительная сложность Производительность алгоритма для самого распространенного, наилучшего и наихудшего случаев применения может быть оценена путем анализа сложности и выражена через О-нотацию. К примеру, какой из множества различных алгоритмов сортировки данных следует вам выбрать? Пузырьковая сортировка — вероятно, самый простой и самый медленный алгоритм сортировки. Его вычислительная сложность равна 0(п2) — это означает, что если удвоить число сортируемых элементов, то время выполнения сортировки увеличится в четыре раза. В то же время алгоритм быстрой сортировки имеет значительно меньшую вычислительную сложность, равную 0(п log ri). В табл. 6.1 приведено сравнение этих двух алгоритмов для малого, среднего и большого количества сортируемых элементов. В ней показано приблизительное количество операций, которые должны выполнить алгоритмы, и видно, на сколько больше операций требуется алгоритму пузырьковой сортировки по сравнению с алгоритмом быстрой сортировки.
Выбор команд 69 Таблица 6.1. Приблизительное количество операций, выполняемых алгоритмами сортировки Пузырьковая сортировка Быстрая сортировка 256 элементов 65 000 2048 1000 элементов 1 000 000 9965 10 000 элементов 100 000 000 133 000 Как видите, в случае большого количества элементов при быстрой сортировке требуется выполнить примерно в 1000 раз меньше операций, чем при пузырьковой. Не важно, сколько усилий вы приложите к реализации и настройке алгоритма пузырьковой сортировки, вследствие природы и вычислительной сложности этих двух алгоритмов пузырьковая сортировка никогда не будет выполняться быстрее, чем быстрая. В двух примерах сортировки в главе 2 (см. пример 2.2) показана аналогичная зависимость производительности от вычислительной сложности. Вычислительная сложность различных алгоритмов может служить для оценки их относительной производительности — она дает примерное количество операций, которое будет выполнено алгоритмом. При выборе одного из двух алгоритмов, имеющих одинаковую вычислительную сложность, следует принять во внимание и другие факторы, влияющие на производительность. Более детальное обсуждение алгоритмов и их вычислительной сложности вы можете найти в дополнительной литературе [12, 28]. Выбор команд Используемые в реализации алгоритма команды могут оказать значительное влияние на производительность и, как следствие, на выбор алгоритма. Некоторые команды, такие как целочисленное сложение, выполняются очень быстро, в то время как другие (к которым, например, относится целочисленное деление) — медленно. Скорость выполнения команды определяется ее латентностью и пропускной способностью. Латентность команды — это число тактов, необходимых для завершения одной команды с момента готовности входных данных команды (выборки их из памяти) и начала ее выполнения. К примеру, латентность целочисленного умножения составляет приблизительно 9 тактов на процессоре Pentium® 4. Это означает, что результат умножения будет готов только через 9 тактов после начала выполнения. Пропускная способность команды — это число тактов ожидания, которое требуется процессору перед запуском на выполнение такой же команды. Пропускная способность команды всегда меньше либо равна латентности команды. Пропускная способность умножения в 4 такта означает, что каждые 4 такта может начать выполняться новое умножение, несмотря даже на то, что для получения результата конкретного умножения требуется 9 тактов. Причиной разницы между числом тактов пропускной способности и числом тактов латентности является конвейерная обработка команд.
70 Глава 6 • Алгоритмы Значения латентности и пропускной способности большинства команд процессоров Pentium® 4 и Pentium® M приведены в руководстве «IA-32 Intel Architecture Optimization Reference Manual». Ссылки на электронные версии этого руководства и на руководства по другим продуктам Intel вы найдете в списке литературы. Учет значений латентности и пропускной способности может существенным образом повлиять на выбор алгоритма. К примеру, на процессоре Pentium® 4, если в одном алгоритме используется десять операций сложения, а во втором — только одно деление, то версия со сложением будет быстрее, поскольку деление выполняется в 40 раз дольше, чем сложение. Нахождение наибольшего общего делителя двух чисел является хорошим примером использования показателей латентности и пропускной способности для выбора алгоритма. В начальных классах средней школы детей учат находить наибольший общий делитель двух чисел, выполняя следующие шаги: 1. Разложите каждое число на множители. 2. Найдите у чисел общие множители. 3. Перемножьте общие множители, чтобы получить наибольший общий делитель. Пример 6.1. Школьный способ нахождения наибольшего общего делителя чисел 40 и 48 1. Разложите каждое число на множители: 40 = 2x2x2x5 48 = 2x2x2x2x3 2. Найдите у чисел общие множители: 2x2x2 3. Перемножьте общие множители, чтобы получить наибольший общий делитель: 2x2x2=8 Очевидно, что школьный алгоритм слишком трудоемок для компьютера; уже на первом шаге разложение двух чисел на множители заняло бы много времени. К счастью для нас, Эвклид давно отыскал1 более быстрый алгоритм нахождения наибольшего общего делителя. Вот алгоритм Эвклида: 1. Большее из чисел = большее из чисел - меньшее из чисел. 2. Если числа равны, значит, это наибольший общий делитель. В противном случае вернуться к шагу 1. Не совсем так. Этот алгоритм был известен, по крайней мере, за 100 лет до упоминания в «Началах» Эвклида под названием «антифайресис», или «последовательное взаимное вычитание». — Примеч. перев.
Выбор команд 71 Пример 6.2. Нахождение наибольшего общего делителя чисел 40 и 48 с помощью алгоритма Эвклида и повторяющегося вычитания 1. 48,40 -> 48-40, 40 -> 8, 40. 2. 8 Ф 40, поэтому возвращаемся к шагу 1. 3. 8,40 -> 40-8,8 -> 32, 8. 4. 8 ;* 32, поэтому возвращаемся к шагу 1. 5. 8,32 -> 32-8, 8 -> 24,8. 6. 8 Ф 24, поэтому возвращаемся к шагу 1. 7. 8,24-» 24-8,8 -> 16, 8. 8. 8*16, поэтому возвращаемся к шагу 1. 9. 8,16-» 16-8, 8 ->8,8. 10. 8 = 8, следовательно, 8 и есть наибольший общий делитель. Алгоритм Эвклида на языке С может быть записан так: int find_gcf(int a, int b) { /* предполагаем, что а и b больше 0 */ while (1) { if (a > b) а = а - b; else if (a < b) b = b - а; else /* они равны */ return a; } } Компилятор Intel создает для этой функции ассемблерный код, приведенный ниже. Из него хорошо видно, что каждая итерация цикла требует одного сравнения, двух или трех переходов и одного вычитания. Для случая, когда а = 48 и b = 40, эта реализация алгоритма выполняет 5 сравнений, 14 переходов и 5 вычитаний, что в сумме составляет 24 команды. _find_gcf$:: $В2$2: cmp eax, edx jle $B2$4 sub eax, edx jmp $B2$2 $B2$4: jge $B2$6 sub edx, eax jmp $B2$2 $B2$6: ret
72 Глава 6 • Алгоритмы Один из вариантов алгоритма Эвклида предполагает вместо вычитания операцию взятия остатка от деления. На языке С это может выглядеть так: int find_gcf(int a, int b) { /* предполагаем, что а и b больше 0 */ while (1) { а = а % Ь; if (а == 0) return b; if (a == 1) return 1; b = b I a; if (b == 0) return a; if (b == 1) return 1; } } И снова изучение ассемблерного кода функции точно показывает, какие команды будут выполняться. Для этого примера со значениями а = 48 и b = 40 сгенерированный код выполняет 2 деления, 3 сравнения, 3 перехода, 4 команды mov и 2 команды cdq. To есть эта реализация использует гораздо меньше команд, всего лишь 14. Поскольку 14 команд меньше, чем 24, то версия со взятием остатка от деления на первый взгляд должна быть быстрее. Однако на процессоре Pentium® 4 для приведенных входных значений алгоритм повторяющегося вычитания будет работать быстрее, поскольку при взятии остатка от деления используется целочисленное деление, требующее 68 тактов, в то время как на вычитание и сравнение необходимо по 1 такту. В данном случае вы можете остановить свой выбор на алгоритме повторяющегося вычитания, несмотря даже на то, что он требует больше команд, поскольку применяемые команды выполняются намного быстрее. Таблица 6.2. Приближенное сравнение двух версий алгоритма Эвклида Версия с повторяющимся вычитанием Команда Вычитание Сравнение Переход Прочее Всего Количество 5 5 14 0 24 Латент- ность 1 1 1 1 Всего тактов 5 5 14 0 24 Версия со взятием остатка от деления Команда Взятие остатка (целочисленное деление) Сравнение Переход Прочее Всего Количество 2 3 3 6 14 Латент- ность 68 1 1 1 Всего тактов 136 3 3 6 148
Выбор команд 73 В табл. 6.2 приводится оценка времени выполнения этих двух алгоритмов с входными параметрами а = 48 и Ь = 40. Для простоты предположим, что время выполнения операции перехода равно 1 такт. Несмотря на то, что алгоритм со взятием остатка от деления требует меньшего количества команд, из-за латентности операции деления он, зачастую, выполняется значительно дольше, чем вариант с повторяющимся вычитанием. Но в зависимости от чисел а и Ъ время выполнения может значительно изменяться. Этот разброс является следствием различий в вычислительной сложности алгоритмов. Рассмотрим случай, когда а = 1000 и Ь = 1. НОД равен 1, и алгоритму с повторяющимся вычитанием потребуется 999 итераций цикла и, по грубым оценкам, 5000 тактов, чтобы получить результат. Алгоритм со взятием остатка от деления завершит вычисление за одну итерацию цикла, выполнив при этом только операцию взятия остатка, операцию сравнения, один переход и затратив всего около 74 тактов. Видя такой разброс результатов, логично было бы объединить оба алгоритма, чтобы воспользоваться достоинствами каждого. Приведенный далее код является примером реализации такого смешанного алгоритма. Там, где это возможно, в нем используются недорогие операции вычитания, а в случаях, когда для достижения результата требуется выполнить много итераций вычитания, применяется более дорогостоящее деление. В табл. 6.3 сравнивается производительность этих трех алгоритмов при выполнении на процессоре Pentium® 4 с тактовой частотой 3,6 ГГц для всех комбинаций аиЬв диапазоне [1...9999]. По мере снижения латентности команды деления версия алгоритма со взятием остатка от деления начинает обгонять смешанный алгоритм. Этот прирост производительности возникает из-за накладных расходов на выбор между вычитанием и делением в смешанном алгоритме, int find_gcf(int a, int b) /* предполагаем, что а и b больше 0 */ while (1) { if (a > (b * 4)) { а = а % Ь; if (а == 0) return b; if (a == 1) return 1; } else if (a >= b) { a = a - b; if (a == 0) return b; if (a == 1) return 1; } if (b > (a * 4)) { b = b % a;
74 Глава 6 • Алгоритмы if (b == 0) return a; if (b == 1) return 1; } else if (b >= a) { b = b - a; if (b == 0) return a; if (b == 1) return 1; } } } Таблица 6.З. Время работы трех различных реализаций алгоритма Эвклида Версия с повторяющимся вычитанием 14,56 с Версия со взятием остатка от деления 18,55 с Смешанная версия 12,14 с Зависимость по данным и параллелизм команд Помимо латентности и пропускной способности команд на возможность процессора одновременно выполнять команды оказывает влияние и зависимость по данным. Как правило, алгоритм будет работать быстрее, если он структурирован так, чтобы процессором могло выполняться одновременно больше команд. Процессор Pentium® 4 способен выполнить шесть команд за один такт, но на практике число одновременно выполняемых команд обычно меньше из-за зависимостей по данным. На диаграмме Ганта (рис. 6.1) показано, как могут совместно выполняться три умножения. Диаграмма предполагает отсутствие зависимостей по данным между командами, что позволяет им выполняться одновременно, будучи ограниченными лишь пропускной способностью команд. Однако в реальном мире зависимости по данным существуют, и они могут привнести существенные изменения. К примеру, если бы три умножения были зависимы по данным, как в выражении a = wxxxyxz, то график выглядел бы совершенно иначе, так как результат w xx не был бы готов к перемножению с результатомyxz в течение 15 тактов. Диаграмма, изображенная на рис. 6.2, гораздо длиннее, так как параллелизм между операциями умножения невозможен. Необходимо отметить, что если пропускная способность и латентность команды одинаковы, то процессор за один раз может выполнить только одну такую команду. Поддержка параллелизма таких команд не повысит производительность, поскольку процессор в любом случае не сможет выполнять их одновременно.
Зависимость по данным и параллелизм команд 75 Умножение а = и х v, b = w х х, с = у х z, [Патентность 15 тактов Рис. 6.1. Пример выполнения команд без учета зависимостей по данным на диаграмме Ганта Умножение a=wxxxyxz 20 25 30 35 Зависимость по данным Рис. 6.2. Выполнение команд с учетом зависимостей по данным на диаграмме Ганта Ограниченность в возможности параллельного выполнения команд, связанная с зависимостью по данным, латентностью и пропускной способностью команд — вот основной фактор, снижающий производительность алгоритма. Существуют дополнительные правила относительно параллелизма команд, специфичные для каждого семейства процессоров. Однако если вы учтете зависимость по данным, латентность и пропускную способность команд, то получите хорошее приближение в плане того, как выполняются команды, поэтому обычно можно игнорировать любые дополнительные уникальные правила параллелизма. Пример 6.3. Оптимизация цикла Выявить зависимости по данным иногда довольно сложно, поскольку они могут скрываться в конструкциях циклов или среди многочисленных функций.
76 Глава 6 • Алгоритмы Задача Уменьшить в представленном далее коде количество скрытых зависимостей по данным с целью повышения производительности. а - 0; for (х = 0; х < 1000; х ++) а + = buffer [х]; Решение Если искать только зависимости по данным, то инкремент переменной х и сложение а + buf fег[х] могут происходить одновременно, поскольку в коде нет зависимостей по данным. Но тут мы упускаем из виду зависимости, которые пересекают границы итераций цикла. Существует зависимость между вычислением а в итерации i и вычислением а в итерации / + 1. Можно написать этот цикл лучше — достаточно использовать четыре сумматора, чтобы за один такт могло выполняться больше арифметических операций (благодаря уменьшению числа зависимостей по данным). a=b=c=d=0; for (x=0; x<1000; х+=4) { а += buffer[x]; b += buffer[x+l]; с += buffer[x+2]; d += buffer[x+3]: } a = a + b + c + d; Хотя каждая итерация в этом «развернутом» цикле выполняет больше команд, он содержит меньше зависимостей по данным, в результате общее число итераций сокращается в четыре раза. Все эти факторы вместе взятые позволяют данному циклу работать быстрее. В примере продемонстрированы те же самые приемы, которые используются при векторной оптимизации компилятора Intel с помощью набора 85Е2-команд, введенных в процессоре Pentium® 4 (см. главы 12 и 13). Причем компилятор делает это сам, вносить изменения в код цикла не требуется. Следует стремиться к снижению зависимостей по данным до такого уровня, чтобы процессор мог выполнять одновременно, по меньшей мере, четыре операции. Требования к памяти Извлечение данных из оперативной памяти — одна из самых медленных операций процессора, и это обязательно должно учитываться при выборе и реализации алгоритма. Алгоритмы имеют свои внутренние требования к памяти, и те из них, которые используют меньший объем памяти, обычно работают быстрее. Одни
Параллельные алгоритмы 77 алгоритмы сортировки упорядочивают элементы «на месте» (в памяти, уже выделенной под входной набор данных), другим требуется дополнительная память. Например, сортировка выбором является типичным примером алгоритма сортировки «на месте», в то время как при сортировке слиянием нужна дополнительная память. Любое преимущество, полученное алгоритмом за счет обращений к дополнительной памяти, может быть утрачено из-за скорости доступа к ней. Оценивая производительность алгоритма, рассматривайте обращения к памяти как команды с высокой латентностью. Определить однозначно величину латентно- сти памяти нельзя, поскольку она зависит от многих факторов, таких как состояние кэша и выравнивание данных. Тем не менее, есть универсальное эмпирическое правило: при первом обращении к некой области памяти загрузка содержимого памяти в кэш будет стоить вам нескольких сотен тактов процессора. После того как данные оказались в кэше, затратами на последующие обращения к той же (или ближайшей) области памяти можно пренебречь. Кроме того, чем больше зависимостей по данным содержит алгоритм, тем более ограничивающей становится латентность памяти, так как процессор больше времени расходует на ожидание доступа к памяти, чем на выполнение команд, не имеющих зависимостей по данным. В главе 8 приводится более подробная информация по настройке алгоритмов с целью достижения более высокой производительности памяти. Параллельные алгоритмы На однопроцессорных системах программы работают быстрее, когда несколько команд могут выполняться параллельно. Мелкомодульный параллелизм, называемый параллелизмом уровня команд, применяется во всех процессорах Intel (начиная с Pentium) для повышения скорости выполнения программ. Когда гораздо более крупные фрагменты программы могут выполняться параллельно, это называется крупномодульным параллелизмом. Многоядерные процессоры могут использовать этот параллелизм для выполнения таких программ за гораздо меньшее время. Поскольку почти все выпускающиеся в настоящее время современные процессоры содержат несколько ядер, имеет смысл применять алгоритмы, поддерживающие крупномодульный параллелизм. Разработку параллельных алгоритмов нельзя свести к простым рецептам. Большинство задач программирования имеет несколько параллельных решений. Наилучшее решение может отличаться от того, что предлагают существующие последовательные алгоритмы. При разработке параллельных алгоритмов на ранних этапах работы учитывают аспекты, не зависящие от аппаратного обеспечения (например, конкуренция), а вопросы, специфичные для конкретного аппаратного обеспечения, рассматриваются на заключительных этапах. Процесс разработки параллельных алгоритмов делится на четыре этапа: разбиение, синхронизация, концентрация и планирование. Первые два этапа призваны обеспечить конкуренцию и масштабируемость — отыскиваются алгоритмы именно с такими качествами. Третий и четвертый смещают акцент на локальность и другие проблемы,
78 Глава 6 • Алгоритмы связанные с производительностью. Далее следует краткое описание этих четырех этапов: □ Разбиение. Вычислительная задача и данные, которыми она оперирует, делятся на небольшие задания. Такие вопросы, как число процессоров и привязка к ним потоков и данных в многоядерных и многопроцессорных системах на этом этапе не рассматриваются, а все внимание сосредотачивается на возможностях параллельного выполнения. □ Синхронизация. Синхронизация требуется, чтобы координировать выполнение заданий, при этом описываются соответствующие схемы распределения данных и алгоритмы. □ Концентрация. Схемы задания и синхронизации, определенные на первых двух этапах разработки, оцениваются согласно требованиям к производительности и стоимости. При необходимости задания укрупняются с целью повышения производительности или сокращения издержек многопоточной обработки. □ Планирование. Каждое задание назначается ядру или процессору, чтобы максимально использовать ресурсы ядра/процессора и снизить издержки синхронизации. Планирование может быть статическим или определяться при выполнении. В главе 15 рассматриваются основные принципы многопроцессорной обработки и дается более конкретная информация о том, как заставить параллельные алгоритмы хорошо работать на многоядерных и многопроцессорных платформах. При выборе алгоритма учитывайте, насколько просто его можно разделить на параллельно выполняемые вычислительные задания, поскольку их часто можно без труда ускорить на процессорах, поддерживающих технологию гиперпоточности, на многоядерных процессорах и многопроцессорных платформах. Универсальность алгоритмов Готовые алгоритмы обычно решают общие задачи. Если вам нужно решить именно такую задачу, то готовые алгоритмы обычно работают очень хорошо. Однако часто задача, которую необходимо решить, несколько отличается от общей. В зависимости от свойств задачи может оказаться, что специфическую задачу можно решить эффективнее, чем общую. При разработке алгоритма и структур данных необходимо сосредоточиться на том, чтобы операции, которые должны выполняться многократно, выполнялись быстро. Редко встречающиеся операции могут выполняться медленнее. Простой пример злоупотребления универсальными средствами — вызов функции strlen, чтобы определить, является ли строка пустой. Вычислительная сложность функции strlen равна О(п), хотя достаточно всего лишь проверить первый элемент строки на равенство значению ' \0'. Таким образом, сложность проверки на равенство пустой строке может быть снижена до 0(1). Часто реализации наборов данных, списков и подобных им структур имеют схожие проблемы производительности, когда универсальный алгоритм применяется для решения
Выявление алгоритмических проблем 79 такой задачи, которую можно было бы решить эффективнее за счет специально приспособленного для этого алгоритма. Выявление алгоритмических проблем Проблемы в алгоритмах можно обнаружить несколькими путями. В одном из методов используется граф вызовов анализатора производительности VTune™. Этот инструмент служит для поиска функций, на долю которых приходится значительная часть времени выполнения, в том числе времени, затрачиваемого самим кодом функции, вызываемыми из нее функциями и времени ожидания объектов синхронизации. На рис. 6.3 приведен пример использования анализатора производительности VTune™ именно таким образом. Граф показывает, что большая часть времени уходит на вызов функции count_id из функции remove_dupl icates. Время выполнения самой функции remove_dupl icates составляет всего 232 000 микросекунд, но если учитывать вызываемые из нее функции, то она тратит 3,49 секунды общего времени. Эта информация помогает выявить те точки в алгоритме или реализации, которые приводят к увеличению времени выполнения, что указывает на плохой алгоритм. Этот метод имеет один недостаток — он не позволяет судить о поведении кода внутри функции. Последнего можно достичь путем выборочного анализа в программе VTune или при помощи инструмента анализа кодового охвата компилятора Intel. HiiiiiML^illil;^liifi'i,iiifliiffliHitfffli^a^HHH^i^HHH^HHHgaa ЯВЬ Е* Sew ficbvfty Configure ffndow Help ., jgj Xf {*&Ъ'-&& 4a -t!UG><5>^ |ActM<y1 (CalGraph) jrj > Ш II К О \ \ *f : :|# ' Рис. 6.З. Анализ графа вызовов в анализаторе производительности VTune
80 Глава 6 • Алгоритмы Компилятор Intel содержит инструмент анализа кодового охвата, который также может быть использован для поиска алгоритмических проблем. Сначала необходимо инструментировать код компилятором Intel, указав ключ компилятора -Qprof -genx под Windows или -prof -genx под Linux. Затем применяют инструмент анализа кодового охвата (codecov) с флагом -counts для создания HTML-файла с исходным кодом и количеством проходов каждой строки. На рис. 6.4 показана та же самая программа примера со значениями количества проходов, полученными с помощью инструмента codecov. Легко увидеть фрагменты кода, исполняемые очень часто. Кроме того, места, в которых велико количество прогонов, обычно указывают на алгоритм с большой вычислительной сложностью. Недостаток данного метода заключается в отсутствии информации о времени выполнения, так что несмотря на то, что количество проходов может быть большим, оно не всегда соответствует времени, затрачиваемому кодом. На рис. 6.4 видно, что функция counti t вызывается 30 миллионов раз, тогда как вложенный цикл функции имеет 3,6 миллиарда проходов. В большинстве случаев подобный рост числа проходов указывает то место в алгоритме, которое требует оптимизации. F 3 Intel <y Compilers code coverage 1 ГНе Ed* View rfiyorites loob L 1 ф Back * U Lxj Z) I information idp for C:\PERF_BOOK\RrM_.DUPS.C Microsoft Internet Explorer , Search Favor.es Q . Щ • Ц @ S | Й C:\perf_book\CodeCoverage\C_PERF_BOOK_REM_DUPS_C.HTML 1 ?3& "Че" 1 covered functions 1 coverage function 1 90.00 (9/10) count it I 95.65 (22/23) main I 90.91 (10/11) remove dupl |.< ^мммишнцг ■ ШН Ы 9) 10) 11) 12) 13) 14) 15) 16) 17) 18) 19) 20) 21) 22) 23) 24) 25) 26) 27) 28) 29) 30) 31) 32) 33) 34) 35) 36) declspec(noinline) int count_it(int *p, int n, int search_val) A 30,000,000 int i; int cnt "0; for (i - 0; i < n; i++) { A 3,659,970,000 (5) if (p[i] — search val) { cnt +« 1; A 28,740,000 } } return cnt; A 30,000,000 } A 29,970,000 (1/2) declspec(noinline) int remove_duplicates(int * in_p, int *out_p, int n A 30,000 { int i; int j - 0; for (i - 0; i < n; i++) { A 90,030,000 (5) if (count_it(out_p, j, in_p[i]) >= 1) { A 30,000,000 /* * we've already put it into output, so * there again */ } - vigjGo don't put it :J My Computer ftlx'i IT I »| #i| VI Рис. 6.4. Применение инструмента анализа кодового охвата с целью выявления алгоритмических проблем
Выявление алгоритмических проблем 81 Если выборочный анализ в анализаторе VTune™ показывает, что горячие точки распределены по множеству функций, вы можете использовать граф вызовов, чтобы увидеть иерархию функций и определить, связаны ли эти функции каким-то образом между собой или являются частью более крупного алгоритма, который можно оптимизировать в целом. В этом примере функции remove dupl icates и count_it должны рассматриваться как части общего алгоритма и оптимизироваться вместе. В данном случае проблема состоит в том, что алгоритм удаления дубликатов берет каждый элемент массива, а затем перебирает весь массив и подсчитывает дубликаты данного элемента, что делает его вычислительную сложность равной 0{п2). В этом случае лучший способ поднять производительность — найти алгоритм с меньшей вычислительной сложностью. На рис. 6.5 приведены данные о количестве проходов, собранные инструментом кодового охвата для улучшенного алгоритма. Значительное сокращение количества проходов свидетельствует о том, что новый алгоритм гораздо эффективнее и имеет сложность 0(п). г C:\PERF ВООК\ШМ ..DUPS1 .С Microsoft Inter File £dt Yjew Favorites look Help i Search Favorites &Щ Ь а ш о * &} C:\perf_book\CodeCoveraoe\C_PERF_BOOI<_REM_DUPSl_C.HTML 10) declspec(noinline) 11) int reroove_dup1icates(int A 30,000 4!EJGo tflEl intel covered functions coverage function 95.65 (22/23) main 93.75 (15/16) remove dupl ^Done in_p, int *out_p, int n) 12) 13) 14) IS) 16) 17) 18) 19) 20) 21) 22) 23) 24) 25) 26) 27) 28) 29) 30) 31) 32) 33) 34) 35) 36) 37) { int i; int j - 0; /• * Clear the marks. */ for (i - 0; i < 42; i++) { A 2,550,000 (4) scratch[i] ж 0; > for (i - 0; i < n; i++) { A 90,030,000 (5) A 30,000 if (scratch[in p[i]] -- 0) out ptj] - in p[i] ; A 1,2 60,000 J +- 1; scratch[in p[i]] = 1; } } return j; A 30,000 } A 60,000 (2/3) main() A 1 { int i, j; int n origs; jf My Computer Рис. 6.5. Количество проходов улучшенного алгоритма, полученное с помощью инструмента анализа кодового охвата (codecov)
82 Глава 6 • Алгоритмы После того как эффективный алгоритм найден, полезно определить степень параллелизма команд в алгоритме, которая покажет количество зависимостей по данным и задействованные команды. Низкая степень параллелизма команд обычно означает, что алгоритм можно усовершенствовать, усиливая параллелизм команд или операций обращения к памяти. Степень параллелизма определяется отношением числа тактов к количеству выполненных команд (Clockticks per Instructions Retired, CPI) в горячей точке. Приблизительное значение этого показателя составляет 0,56 для улучшенной функции remove_duplicates (рис. 6.6). Следовательно, почти две команды завершаются за один такт. Обычно значение CPI, превышающее 1,0, означает, что мощности процессора используются не полностью, и при оптимизации нужно либо сократить количество зависимостей по данным в текущем алгоритме, либо заменить его лучшим. В то же время низкое значение CPI (приблизительно ниже 0,75) означает, что команды выполняются процессором эффективно. Так как хороший показатель CPI значительно зависит от особенностей кода, нельзя использовать во всех ситуациях какое-то его универсальное значение. Ориентируйтесь на показатель CPI только для небольших фрагментов кода, иначе это может привести к ошибкам. flRte £<Й tfev» Activity Configure yflndow ц^ \e xj "3 ► « Й X ' Ф % J3 чу ч Ш lie s Я Group by: jj£ Function Function ► temove_dupicates Events Instructions Retired samptes(80) Clockticks samptes(80) Instructions Retired events(80) Instructions Retired X(80) Clockticks events(80) Clockticks X(80) Cycles per Retired Instruction ■ CPI(80) Total 128.00 7200 217,60... 100.00 122.40... 100.00 0.56 Event ActMtylD Scale Sample After Value Total Seniles Duration(s) RingO Ring3 StartTirre Ш " IrttrjucfocwRetiretf 80 0.00000010000k 1700000 162 013 30 132 8/9/200511:00:29PM KE Clockticks 80 0.00000010000k 1700000 121 013 40 81 8/9/200511:0029 PM KE Cycles per Retired Instruction • CPI 80 100.00000000000k 0 0 0.00 0 0 • terns, 3 events, I item(s) selected. § ttotspots - [оэяршф Rii*»] j TueAiig09 23:Ctt29 2005<k>;ahost>(Run1)Samplirigdata Рис. 6.6. Значения количества тактов (Clockticks), количества выполненных команд (Instructions Retired) и CPI для программы rem_dupsl.exe
Основные моменты 83 Если наблюдается хороший уровень параллелизма команд, но горячие точки еще остаются, обычно можно повысить производительность, сократив количество выполняемых команд. Этого можно добиться, найдя более короткий путь решения или применив другой алгоритм. Основные моменты Запомните представленные далее рекомендации: □ Правильный выбор алгоритма играет исключительно важную роль в достижении высокой производительности. Вычислительная сложность — самый важный показатель производительности алгоритма. Вопросы обращения к памяти, выбора команд и другие аспекты, связанные с процессором, вторичны. □ Латентность и пропускная способность команд, зависимости по данным и обращения к памяти значительно влияют на производительность алгоритмов; их необходимо учитывать при выборе и реализации алгоритма. □ Старайтесь удерживать количество зависимостей по данным на достаточно низком уровне, чтобы процессор мог одновременно выполнять, по меньшей мере, четыре команды. □ Выбирайте такие алгоритмы, которые позволяют производить большую часть вычислений параллельно или с использованием векторных команд. □ Старайтесь приспособить алгоритмы под задачу, чтобы избежать их низкой эффективности. □ Используйте граф вызовов анализатора VTune™ и значения количества проходов, предоставляемые инструментом анализа кодового охвата codecov компилятора Intel, для выявления алгоритмов, являющихся источниками горячих точек.
Переходы Одной из базовых операций любого компьютерного языка является команда условного перехода. К сожалению, команды условных переходов относятся к наиболее проблемным с точки зрения эффективного выполнения процессором, поскольку могут нарушать очередность выполнения команд. Иногда переходы выполняются за один такт, а иногда их выполнение может занять много десятков тактов. Подробное обсуждение причин того, почему переходы являются проблемой для процессоров, можно найти в главе 5. Переходы бывают двух видов: условные и безусловные. Условные переходы означают переход либо к указанной команде (выбранная ветвь), либо к следующей (проход сквозь). Безусловные переходы всегда означают переход на новый адрес. Этот адрес может быть известен заранее (как в случае прямых переходов) или не известен до выполнения (как в случае косвенных переходов). В табл. 7.1 показаны примеры двух типов переходов. Необходимо отметить, что команды вызова также являются формой безусловного перехода. Таблица 7.1. Примеры переходов двух типов Условные переходы if (a > 10) а - 10; do { а++; } while (a < 10); (а > 10) ? а=10 : а=0; for (a=0; а<10; а++) Ь++; while (!eof) Read_another_byte(); Безусловные переходы FnCa); goto end; return a; _asm { int 3 }; fnPointer(a);
Основные моменты 85 Чтобы определить следующую команду при предсказании результата перехода, процессоры Pentium® 4 и Pentium M используют буфер меток перехода (Branch Target Buffer, BTB) и историю переходов. Процессор может корректно предсказать большинство переходов, например как в схеме четный/нечетный в следующем коде: // Простой эталон переходов, который будет правильно // предсказываться процессорами Pentium 4 и Pentium M for (a=0; а<100; а++) { if ((а & 1) == 0) do_even(); else do_odd(); } Обычно процессоры Pentium 4 и Pentium M могут предсказать большинство неслучайных переходов. Переход четный/нечетный — очень правильный и поэтому очень предсказуемый. С одного взгляда легко определить, что в 50 % случаев будет вызываться функция do_even, а в других 50 % случаев — функция do_odd. Важнее всего то, что эталон переходов регулярный. Этот эталон очень отличается от следующего кода, в котором также происходит переход по одному из двух вариантов, но он непредсказуем. // Эталон случайных переходов, которые трудно предсказать for (a=0; а<100; а++) { side = flip_coin(); if (side -p HEADS) NumHeads++; else NumTails++; } Важная разница между этими двумя фрагментами кода состоит в том, что один переход предсказуем, а второй случаен, то есть непредсказуем. Как программист не может предсказать порядок выполнения переходов в примере с подкидыванием монеты, точно так же не может этого сделать и процессор. Для того чтобы наилучшим способом определить, будет ли переход корректно предсказываться, необходимо вообразить себе эталон переходов. Если существует предсказуемый эталон, то процессор почти всегда сможет обнаружить его и правильно предсказать переход. При отсутствии предсказуемого эталона процессор в некоторых случаях прогнозирует неверный результат перехода, и производительность падает.
86 Глава 7 • Переходы Некоторые случайные переходы неизбежны (такие как цикл оконного сообщения). Может показаться, что в таких ситуациях было бы выгодно отключить механизм предсказания переходов, но это невозможно и нежелательно. Отказ от предсказания переходов — это скорее упущенная возможность, чем наказание. Без предсказания переходов процессору пришлось бы останавливаться на каждом переходе и ждать много тактов окончания выполнения перехода в конвейере процессора. В то же время потери производительности при отключенном механизме предсказания переходов и при восстановлении после неверно предсказанного перехода примерно одинаковы. Поэтому предсказание переходов — это всегда хорошо. Важно избежать непредсказуемых переходов (особенно в критичном в плане производительности коде), чтобы получить максимальный выигрыш в производительности. Поскольку всех неверно предсказанных переходов избежать невозможно, некоторые их них произойдут даже в критичных областях. Даже в самых простых циклах, наподобие показанного ранее примера с четными/нечетными переходами, как правило, имеется один неверно предсказываемый переход — последнее сравнение для выхода из цикла. Точно так же как и в случае с другими проблемами производительности, следует совершенствовать только те неверно предсказываемые переходы, которые вызывают существенную потерю производительности. Поиск критичных переходов среди неверно предсказываемых На веб-сайте поддержки этой книги имеется иллюстративный пример программы (MISPRED.C), позволяющей найти среди неверно предсказываемых переходов критичные и усовершенствовать их. Обнаружить подлежащие оптимизации переходы можно с помощью анализатора VTune, выполнив перечисленные в следующих разделах шаги. Шаг 1. Поиск неверно предсказываемых переходов В первую очередь необходимо обнаружить неверно предсказываемые процессором переходы. Счетчик событий Mispredicted Branches Retired анализатора VTune увеличивает свое значение при каждом неверном предсказании перехода, поэтому его можно использовать для выборки этого события. На рис. 7.1 показаны результаты выборки при выполнении программы MISPRED.EXE. Результаты выборки показывают, что все неверно предсказанные переходы происходят в функции check_3odd. To есть эта функция теперь должна стать объектом дополнительного анализа переходов. Шаг 2. Поиск горячих точек потери времени Очень важно проработать те фрагменты приложения, которые расходуют много времени. На шаге 1 мы выявили только те фрагменты, в которых имеет место значительное
Поиск критичных переходов среди неверно предсказываемых 87 |fjle Е* ¥«w activity Configure itfndow ЦЫр jActivityl (Sampkng) *3> я их «^ J«I*J ^ ф J О i Ш Щ | Ш | & **»* IP Thread 01 Module j £] Hotspot © *»*« Everts Total Branch Mispredictions Retked $ampte$(42) 157.485.00 Branch Instructions Retired samples(42) 617,553.00 Instructions Retked samples(42) 9,50200 Oockticks samples(42) 11,948.00 Branch Mispredrcticns Retired event$(42) 787,425,... Branch Mispredcticns Retked X(42) 100.00 Branch Instructions Retked events(42) 3.087,79... Dranch Initrucbon» Г) otked Sf{42) 100.00 Instructions Retkedevents(42) 16.153,4... Instructions Retked Щ2) 100.00 Oockticks e vents(42) 20,311,6.. Oockticks X(42) 99.95 Branch Instructions Retked Ratio(42) 0.19 Branch Mispredicted per Instruction Retked(... 0.05 Branch Mispredction per Branch Instruction... 0.26 Branch Prediction Fate(42) 74.50 Cycles per Retked Instruction -CPI(42) 1.26 E Branch Mispredictions Retked Branch Instructions Retked 42 Instructions Retked 42 Oockticks 42 Branch Instructions Retked Ratio 42 В ranch M ispr edicted per I instruction R etkec 42 Branch Misprediction per Branch Instruction Retked 42 В ranch Prediction R ate 42 Activity ID ■Scale Sample After Value Total Samples Duration (s) RingO Ring3 Start л 0.0000001 OOOOx 000000001OOOx 0.000000001 OOx 0.000000001 OOx 100 00000000000X 0 1000 0000000000CX 0 lOOOOODOOOOOOOx 0 LOOOOOOOOOOOx 0 5000 5000 1700000 1700000 157876 634773 9670 12274 13.78 258 157618 5/30 13.78 14287 620486 5/30 12.32 12.32 0.00 0.00 0.00 0.00 9537 5/30 12008 5/30 6 items, 9 events, 1 fcem(s) selected. Sampling Мо**м - [Sampling P*ruk l.mref»»H.»> «Г For Help, press R Рис. 7.1. Неверно предсказанные переходы во время выполнения программы MISPRED.EXE количество неверно предсказанных переходов, что не обязательно означает, что данный фрагмент расходует столько времени, что становится целесообразной оптимизация. Поэтому важно выяснить, является ли функция check_3odd еще и горячей точкой в смысле расходования времени выполнения. Самым быстрым способом определить, сколько времени расходует данная функция, является использование анализатора VTune с выборкой Clockticks (такты таймера), как показано на рис. 7.2. Граф горячих точек на рис. 7.2 показывает, что почти все время выполнения данной программы расходует функция check_3odd. Поскольку check_3odd отличается также значительным количеством неверно предсказанных переходов, она требует дополнительного анализа и, возможно, оптимизации кода предсказания переходов. Шаг 3. Определение процента неверно предсказываемых переходов Завершающим шагом является определение отношения общего количества переходов к количеству неверно предсказанных переходов. Контроль за этим отношением,
88 Глава 7 • Переходы HEte Ed* fm> 6cttv*y Configure Wndow Help 4» tfi? ?i 0? tj U C2> <53 -^ fActivityl (SampfcigP JLelM ~лЗ> ■ ■■ * *^ ranch Mispredictions Retired samples(42) Branch Instructions Retired samples(42) Instructions Retired samples(42) Oockbcks samples(42) Branch Mispredctions Retired events(42) Branch Mispredictions Retired X(42) Branch Instructions Retired events(42) Branch Instructions Retired 2(42) Instructions Retired events(42) Instructions Retired 2(42) Oockticks events{42) Ciockticks 2(42) Branch Instructions Retired Ratio(42) Branch Mispredicted per Instruction Retned(. Branch Misprediction per Branch Instruction . Branch Prediction Ra»e(42) Cycles per Retired Instruction - CPI(42) Total 157.485.00 G17.559.00 9.502.00 11.948.00 787.425... 100.00 3.087,79... 100.00 16.153,4... 100.00 20,311.6... 99.95 0.19 0.05 0.26 74.50 1.26 Event Branch Mispredictions Retired Branch Instructions Retired Instructions Retired Ciockticks Branch Instructions Retired Ratio Branch Mispredicted per Instruction Retired Branch Misprediction per Branch Instruction Retired Branch Prediction Rate Activity ID Scale 42 00000001OOOOx 42 0.00000001 OOOx 42 0.000000001 OOx 42 0.000000001 OOx 42 100 00000000000X 42 1000 00000000000X 42 100 00000000000X 42 1 OOOOOOOOOOOx Sa-nple After Value Total Samples Duration (sj RingO Ring3 Starts 5000 5000 1700000 1700000 157876 634773 9670 12274 13.78 258 157618 5/30 13.78 14287 620486 5/30 12.32 12.32 0.00 0.00 0.00 0.00 9537 5/30 12008 5/30 * |6 items, 9 events, 1 *em(s) selected. Sampling Modules • [Sampling Results (mispred.e: [Sampling Results [mispred exe - sto«»Il j For Help, press Fl Рис. 7.2. Горячие точки (в смысле расходования времени выполнения) в программе MISPRED.EXE которое является горячей точкой только потому, что выполняется много раз, гарантирует, что усилия по оптимизации не будут потрачены впустую. Имеет смысл оптимизировать только те переходы, которые расходуют время и часто (относительно числа проходов) неверно предсказываются. Если переход предсказывается неверно в одном случае из 1000 — не стоит тратить время на его оптимизацию. В то же время соотношение неверных предсказаний 1 к 2 определенно стоит оптимизировать. Критической точкой является соотношение примерно 1 к 20. Счетчик событий Branches Retired считает каждый переход, и при сравнении этого числа с количеством событий Mispredicted Branches Retired можно определить отношение между ними и принять решение, начинать оптимизацию переходов или нет. Анализатор VTune также может сделать это для вас. На рис. 7.3 показано, что показатель неверных предсказаний в функции check_3odd составляет 0,255 на один переход, а это означает, что 1 переход из 4 предсказывается неверно. Таким образом, частота успешных предсказаний составляет около 75 %. Средним значением считается примерно 95 %. При значении в 90 % почти всегда можно найти возможность для значительного повышения производительности приложения (если, конечно, удастся уменьшить количество неверных предсказаний переходов). Значение частоты успешных предсказаний 75 % — это очень мало, и при таком его
Поиск критичных переходов среди неверно предсказываемых 89 Рис. 7.3. Показатель успешных предсказаний переходов уровне можно ожидать достижения значительного повышения производительности, добившись лучшей предсказуемости переходов. Окончательная проверка готовности При выполнении предыдущих трех шагов предполагалось, что анализ выборочных данных на уровне функции эквивалентен анализу данных на уровне перехода или команды, что не всегда справедливо. В большинстве ситуаций это очень хорошая аппроксимация, но в некоторых случаях подобное допущение может ввести в заблуждение. При наличии больших функций, имеющих как фрагменты с переходами, так и не связанные с ними фрагменты, в которых активно расходуется время выполнения, эти функции могут ошибочно попасть в число подлежащих оптимизации производительности. Самым простым способом избежать такой ошибки является изучение исходного кода, чтобы убедиться, что неверные предсказания переходов происходят в тех же местах, в которых тратятся такты таймера. Задайте также себе вопрос, не содержит ли непредсказуемых переходов код, который предположительно будет расходовать значительное время выполнения. Вы можете изучить каждую функцию в анализаторе VTune, увидеть фактическое положение мест выборки значений и сравнить места выборок для событий Clocktick
90 Глава 7 • Переходы и Mispredicted Branches. Если эти места не совпадают, то возможно, для оптимизации требуется не совершенствование предсказания переходов, а нечто иное. Теперь, когда мы нашли переходы, которые имеет смысл оптимизировать, настало время вносить поправки в код. Различные типы переходов С точки зрения оптимизации переходы можно сгруппировать в пять категорий. □ Условные переходы, выполняемые впервые. Это те переходы, которые не выполнялись раньше или, по крайней мере, недавно. В языке С условные переходы создают условные инструкции (такие как i f, do/whi 1 е и for). В языке ассемблера семейство условных команд jcc (таких как jz и jne) также создают условные переходы. Процессор пытается предсказать эти переходы на основе правил, которые отличаются на разных процессорах. В тех редких случаях, когда для ранее не выполнявшихся переходов важна производительность, лучше всего считать, что выполнение никогда не пойдет по данной ветви. В ситуациях, когда первый проход по коду критичен в смысле времени, повысить производительность практически невозможно. Гораздо лучшие результаты можно получить от достижения «долгосрочной» предсказуемости условного перехода, чем от оптимизации его первого выполнения. Вообще говоря, если вы оптимизируете код для «прохода сквозь» (по невыполнению условия), то в большинстве случаев это означает, что вы уже предприняли необходимые шаги для оптимизации перехода при первом «проходе сквозь» (если это имеет значение). □ Условные переходы, которые выполнялись более одного раза. Эти условные переходы выполнялись ранее или, по крайней мере, недавно, и результаты предыдущих переходов до сих пор находятся во внутреннем буфере меток переходов процессора. Эти переходы предсказываются по сохраненной истории переходов. Когда переход часто предсказывается неверно, динамическому алгоритму предсказания переходов приходится нелегко. Для повышения производительности важно удалить переход такого типа или снизить степень его непредсказуемости, но делать это надо только в том случае, если он расходует значительное время. Для процессора Pentium M также лучше, когда выполнение не идет на переход, даже в тех случаях, когда переход хорошо предсказывается. Для правильно предсказанных переходов, когда выполнение пошло на переход, происходит небольшая задержка (1 такт). Эта задержка происходит при декодировании команды в архитектуре Pentium M, когда процессор получает с адреса перехода буфер с командами для декодирования. В процессоре Pentium 4 благодаря использованию кэша трасс такой задержки не происходит, поэтому на процессоре Pentium 4 попадание на переход не дороже, чем проход сквозь него. Для оптимизации часто выполняемых переходов лучше всего сначала попробовать сделать переходы как можно более предсказуемыми, а затем сделать самую часто выполняемую ветвь «проходом сквозь». Такой подход хорошо оптимизирует
Различные типы переходов 91 программное обеспечение как для Pentium M, так и для Pentium 4. Оба эти процессора поддерживают события, которые можно использовать для проверки на неверно предсказанные условные циклы в анализаторе VTune. Задействуйте событие Conditional Branch Instructions Executed для Pentium M и Mispredicted conditionals для Pentium 4. □ Вызов и возврат. Для каждого вызова адрес возврата помещается во внутренний буфер стека возвратов процессора. Буфер стека возвратов имеет ограниченный размер в 16 элементов. Когда процессор выполняет команду вызова, он помещает адрес возврата в буфер стека возвратов. Если буфер стека возвратов переполняется, он просто округляется и перезаписывает самый старый элемент стека. Когда процессор выполняет инструкцию возврата, верхний элемент буфера стека возвратов выталкивается и используется как предсказанный адрес. Предсказание вызова/возврата заканчивается неудачей, если в вашем приложении не для каждой команды вызова имеется соответствующая команда возврата. Оно заканчивается неудачей и в том случае, если происходит переполнение буфера стека возвратов, что приводит к потере элемента, по которому в будущем должен был бы произойти возврат. Поэтому потом, когда команда возврата выполняется, происходит опустошение стека, и возврат оказывается предсказанным неверно. Вы можете оптимизировать ветви возвратов, отказавшись от вызовов, не имеющих соответствующих инструкций возврата. Необходимо также учитывать глубину цепочек вызовов в горячих точках программы. Если код регулярно выполняет цепочку вызовов глубиной более 16, тогда внутренний буфер стека возвратов может переполняться. Как уже отмечалось, такое переполнение может вызывать неверные предсказания возвратов. Для того чтобы избежать этого, можно задействовать подставляемые функции (для уменьшения глубины стека вызовов в часто выполняемом коде). Для отслеживания этой проблемы на процессоре Pentium 4 можно использовать событие Mispredicted returns анализатора VTune, а на процессоре Pentium M — событие Mispredicted Return Branch Instructions Executed. □ Косвенные вызовы и переходы (указатели функций и таблицы переходов). Первые процессоры Pentium 4 в случае косвенных вызовов предсказывали, что переход произойдет на тот же самый адрес, что и в прошлый раз. Более поздние процессоры Pentium 4 и все процессоры Pentium M имеют более сложный механизм предсказания косвенных переходов. Косвенные переходы может быть трудно предсказать, поскольку код может иметь бесконечное количество целей перехода, в отличие от условных переходов, у которых есть только два места перехода: следующая команда и точка перехода. Оптимизируйте косвенные вызовы и переходы, снижая степень непредсказуемости адреса перехода. Иногда в этом помогает тестирование наиболее вероятного варианта с использованием условного перехода и последующая его замена таблицей переходов. В языках C/C++ такой тест обычно выполняется путем размещения инструкции i f перед инструкцией swi tch, которая проверяет самый общий вариант. Такую же оптимизацию можно произвести и для косвенных вызовов, проверяя, ссылается ли
92 Глава 7 • Переходы указатель функции точно на целевую функцию, и затем вызова напрямую этой часто используемой функции. События анализатора VTune могут помочь найти неверно предсказанные косвенные переходы. Для процессора Pentium 4 задействуйте событие Mispredicted indirect branches, а для процессора Pentium M — событие Mispredicted Indirect Branch Instructions Executed. □ Безусловные прямые переходы. Такие переходы всегда корректно предсказываются. С этим типом переходов не связано никаких проблем производительности, если не считать выборки команд и промахов кэша, поэтому оптимизация им обычно не требуется. Однако как и в случае условных переходов, когда выполнение идет на переход, на процессоре Pentium M происходит задержка в один такт перед тем, как начинается декодирование команд по адресу перехода. Переходы можно оптимизировать несколькими способами. Вы можете сделать их менее непредсказуемыми или заставить чаще срабатывать «на проход сквозь». Еще можно вообще отказаться от перехода. Какой бы метод вы ни избрали, используйте тест производительности, чтобы проверить, действительно ли имеет место рост производительности. Иногда неверно предсказанный переход «стоит» всего нескольких тактов, что затрудняет его улучшение. В следующих разделах перечислены разные типы переходов и стратегии их оптимизации. Предсказуемость переходов Очень часто упускается из вида возможность путем внесения минимальных изменений сделать переходы более предсказуемыми. Рассмотрим следующий пример кода из программы MISPRED.EXE, в которой ранее мы наблюдали столь плохое предсказание переходов. С функцией chech_3dd был связан очень низкий процент успешных предсказаний переходов, а причиной является следующий код: if (tl == 0 && t2 == 0 && t3 m 0) В этом коде каждое из условий, разделенных оператором &&, вычисляется с помощью отдельной команды перехода. Каждая из переменных tl, t2 и t3 равна О или 1 в зависимости от того, четным или нечетным является некое случайное число. Таким образом, вероятность того, что каждая из этих переменных равна 0 или 1, составляет примерно 50 %, причем каждый из переходов, связанных с проверкой tl == 0, t2 == 0 и t3 == 0, не очень предсказуем. В то же время, если бы можно было использовать только один переход вместо трех, то вероятность перехода была бы 0,125 для ветки then и 0,875 для ветки false. Это означало бы лучшую предсказуемость переходов. Для этого код можно переписать следующим образом: if ((tl | t2 | t3) == 0) После внесения этого изменения, повторной сборки программы MISPRED.EXE и ее выполнения мы увидим, что процент успешно предсказанных переходов увеличился с 74,5 до 92. Время выполнения этого простого примера сократилось с 12,3
Удаление переходов с помощью команды CMOV 93 (исходный вариант) до 6,3 секунд (исправленный код), что означает 50-процентное снижение времени выполнения благодаря значительному увеличению процента успешно предсказанных переходов. Предсказуемость часто можно увеличить путем изменения очередности проверки условий в коде и за счет простых изменений кода (таких как в предыдущем примере). Удаление переходов с помощью команды CMOV Переходы, от которых не сложно отказаться — это переходы типа «проверить и присвоить». Рассмотрим следующий код (опять из примера программы MISPRED. ЕХЕ): // В С if ((tl | t2 | t3) == 0) { t4 = 1; } // В ассемблере tl - edi. t2 - ebx. t3 - ebp, t4 - eax or edi. ebx or edi, ebp jne LI mov eax, 1 LI: Это условный переход по значению выражения, вероятность которого равна 0,125, как мы уже видели. Однако процент успешных предсказаний составлял у нас по-прежнему только 92, что ниже желаемого. Приведенные команды очень эффективны, поскольку используются только три зависимых по данным команды, когда одно из значений ненулевое, и четыре — когда все значения равны нулю. Таким образом, превзойти этот код по производительности сложно (когда переход весьма предсказуем). Тем не менее, эти команды по-прежнему оставляют возможность неверного предсказания переходов. Переход такого типа может быть удален при помощи команды CM0V, которая впервые появилась в процессоре Pentium Pro. Процессор Pentium с технологией ММХ и более старые процессоры не имеют команды CM0V, так что использование этой команды со старыми процессорами ситуацию не исправит. Приведенный ранее ассемблерный код может быть переписан так: nov ecx, 1 or edi, ebx or edi, ebp cmove eax, ecx
94 Глава 7 • Переходы К сожалению, команда CM0V может использовать регистр только в качестве места назначения, а в качестве источника — регистр или адрес в памяти, так что для загрузки непосредственного значения требуется дополнительная команда. Однако команду CM0V очень легко применять с подставляемым ассемблерным кодом или флагами -Qx* компилятора C++ производства Intel. Флаги -Qx* говорят компилятору, что при компиляции кода можно без опасений задействовать команду CM0V. Версия кода с командой CM0V содержит четыре команды, из которых только три являются зависимыми по данным, так как первые две могут выполняться одновременно. При наличии команды CM0V предсказуемость условия не имеет значения для времени выполнения, поскольку всегда выполняются все команды. На первый взгляд может показаться, что выполнение этих команд займет больше времени, чем версия с переходом, поскольку всегда выполняются четыре команды. И это было бы справедливо, если бы не потери производительности на неверно предсказанные переходы. Если предсказуемость низкая, то время теряется на неверно предсказанных переходах, что оставляет массу возможностей для того, чтобы версия с командой CM0V оказалась более быстрой. Можно быстро поставить с программой MISPRED.EXE эксперимент по оценке производительности. На этот раз файл mispred.C скомпилируем с флагом -QxK, чтобы разрешить команду CM0V. В то время как предыдущая версия выполнялась 6,2 секунды, версия с командой CM0V выполняется за 4,9 секунды. И данные анализатора VTune показывают, что процент успешного предсказания переходов повысился до 99,9. То есть версии с переходом и с командой CM0V примерно одинаковы тогда, когда процессор неверно предсказывает 1 из 100 переходов. Таким образом, упрощенно получается, что каждое неверное предсказание перехода «стоит» примерно 50 зависимых по данным команд, выполняемых за один такт. Решение об использовании перехода или команды CM0V принимается на основании информации о переходе. Если результаты предсказания перехода плохие, следует обратиться к версии с CM0V, поскольку в ней отсутствует переход и связанные с ним неверные предсказания. Однако если данные поступают равномерно и в основном они предсказуемы, то следует выбрать версию с переходом, поскольку она будет работать быстрее. Для дальнейшей оптимизации переходов требуется задействовать меньше команд, зависимых по данным, или выполнять больше работы каждой такой командой. Удаление переходов с помощью масок Маска может быть создана SIMD-командой РСМР. Команда проверяет условие, а затем устанавливает регистр в одни единицы (OxFFFFFFFF) или одни нули. Для получения желаемых результатов с маской используются арифметические команды OR и AND, как показано в следующем фрагменте псевдокода, где переменная урезается до 255:
Удаление переходов с помощью масок 95 test (val > 255) генерируем маску со следующими свойствами: mask = l's if val > 255 and O's if val <= 255 mask = maskl AND val val = (maskl AND 255) OR (val ANDNOT maskl) Вы можете выбрать одну из двух немного отличающихся версий создания маски командой РСМР: в одной используют 8-байтные ММХ-регистры, в другой — 16-байт- ные SSE-регистры1. Вы должны решить, какую из них использовать, основываясь на количестве и выравнивании данных, которые обрабатываются. Поддержка маски для SIMD-команды осуществляется в компиляторе Intel C++ при помощи подставляемого ассемблерного кода, специальных внутренних команд компилятора и библиотек классов C++. Следующий исходный код одновременно урезает восемь коротких значений до 255 без применения переходов. В этом коде задействованы внутренние 85Е2-команды, предоставляемые компилятором C++ производства Intel. __ml28i va!8; // восемь 16-разрядных целых чисел со знаком для урезания __ml28i vec255; __ml28i mask; vec255 « _mm_setl_epil6(0x00ff); mask = _mm_cmpgt_epil6(val8, vec255); val8 = _mm_or_sil28( _mm_and_si128(mask, vec255). _mm_andnot_si128(mask, val8)); To же самое на ассемблере: movdqa xmm2, XMMWORD PTR _2il0floatpacket$l XMMWORD PTR _va!8 xmmO xmm2 xmml xmmO xmml RD PTR _val8. xmm2 В этом случае используются пять команд, зависимых по данным, но эта SIMD- версия обрабатывает одновременно сразу восемь значений, поэтому в расчете на одно значение SIMD-версия оказывается очень быстрой. Выполнение восьми операций одновременно и наличие только пяти зависимых по данным команд означает, что на один результат нужно всего 0,625 команды. Такое улучшение можно было бы также реализовать с помощью ММХ-регистров и ММХ-команд, но в этом movdqa movdqa pcmpgtw pand pandn рог movdqa xmmO xmml xmml xmm2 xmml xmm2 XMMWl 1 Аббревиатура SSE означает Streaming SIMD Extensions (потоковые SIMD-расширения). — Примеч. ред.
96 Глава 7 • Переходы случае обрабатывается только четыре значения, так что на один полученный результат получается примерно 1,25 команды. В обоих случаях полученный выигрыш огромен даже по сравнению с предсказуемым переходом. Удаление переходов с помощью команд min/max В специальном случае урезания значений вы можете использовать еще более быстрый метод, чем команды масок. Набор 85Е2-команд содержит команды, которые выполняют операции min и max. Эти команды реализуют самую быструю последовательность урезания переменной, как показано в следующем примере кода на обычном языке С (с выполнением векторизации компилятором C++ производства Intel для того, чтобы получить идеальный код): short агт[1000]; int i; for (i = 0; i < 1000; i++) { if (arrCt] > 255) { arr[i] - 255; } Компилятор создает такой ассемблерный код: movdqa xmmO. XMMWORD PTR _2il0floatpacket$l xor eax. eax $B1$2: movdqa xmml. XMMWORD PTR _arr[eax] pminsw movdqa add emp jb xmml, xmmO XMMWORD PTR eax. 16 eax, 2000 $B1$2 _arr[eax], xmml В этой последовательности имеется только три зависимые по данным команды, поэтому при использовании 16-байтных SSE-регистров на получение одного результата требуется только 0,375 команды. Удаление переходов за счет дополнительной работы Часто переходы вводятся для того, чтобы можно было не делать какой-то дополнительной работы. Например, в следующем коде перед вызовом функции blend проверяется значение al pha на 0 и 255.
Основные моменты 97 for (i=0; i<BitmapSize; i++) { SrcAlpha = GetAlpha(SrcPixel[i]); if (SrcAlpha m 255) DstPixel[i] = SrcPixelCi]; else if (SrcAlpha != 0) DstPixel[i] = blend(SrcPixel[i]. DstPixel[i], SrcAlpha); // в противном случае, когда SrcAlpha=0, ничего не делать // оставить DstPixel } Когда результат операции bl end известен заранее (как при значении SrcAl pha равном 0 или 255), то функцию Ы end вызывать незачем, и если бы функция Ы end была очень медленной, то такая оптимизация была бы хорошей, поскольку она избавляет от лишней работы. Но если значения SrcAlpha являются случайными, то переходы часто будут предсказываться неверно, и производительность упадет. То есть можно получить некий выигрыш в производительности: несмотря на то, что какое-то время теряется из-за нескольких неверно предсказанных переходов, много времени экономится за счет невыполнения вызовов функции Ы end. В некоторых случаях, особенно когда данные случайны, вы можете обнаружить, что полезнее удалить переходы и обрабатывать все пикселы (или данные) одинаковым образом. После того как решение удалить переходы принято, можно заняться повышением производительности для оставшейся части работы. Удаление переходов означает также, что программа может задействовать SIMD-команды, а производительность кода больше не зависит от данных. Следующий псевдокод выполняет тот же самый цикл уже без переходов и может использовать SIMD-команды: for (i=0; i<BitmapSize; i+=4) Blend4Pixels (SrcPixel+i. DstPixel+i); Конечно, для получения максимального выигрыша функции должны быть хорошо оптимизированными, а вы должны написать функцию Bl end4Pi xel s () либо с использованием SIMD-команд, либо таким образом, чтобы система векторизации смогла создать векторизованный код с помощью SIMD-команд. Этот метод описывается более подробно в главе 13. Основные моменты При оптимизации переходов следуйте представленным далее рекомендациям: Q Оптимизируйте те переходы, которые являются горячими точками как по затрачиваемому времени, так и в смысле неправильного предсказания переходов, имея высокий процент неверных предсказаний. Игнорируйте все остальные
98 Глава 7 • Переходы переходы. Стоит улучшать только те неверно предсказанные переходы, которые вызывают значительные потери времени. □ Для получения максимального выигрыша попытайтесь удалить переходы при помощи масок и SIMD-команд. □ Используйте для удаления переходов команду CM0V тогда, когда SIMD-команды задействовать не удается. □ Улучшайте предсказуемость переходов (которые нельзя преобразовать в линейный код). Для этого изменяют очередность переходов и пишут код так, чтобы на переходе чаще всего имел место «проход сквозь». □ Используйте тест производительности, чтобы наблюдать за изменениями производительности до и после оптимизации переходов (чтобы заметить повышение производительности).
Память Ничто не влияет на производительность программного обеспечения более глобально, чем быстродействие памяти. С точки зрения современного процессора, память абсурдно медленная. Настолько медленная, что на деле ее производительность ограничивает возможности почти каждого приложения. Медленная память снижает производительность, поскольку заставляет процессор перед выполнением команды ждать выборки операндов команды из памяти. Ожидающие команды занимают место в пуле команд, который может заполниться, после чего процессору будет нечего делать. Запись в память также снижает производительность, поскольку буферы, которые используются для записи данных в память, могут заполниться в ожидании записи в медленную память. Программисту эти ожидания представляются в форме больших значений латентности команд, которые означают, что команды выполняются дольше, чем предполагается. Например, следующий код суммирует массив: total«О; for (1-0; i<1000; i++) total += array[i]; Процессор выполняет цикл в пять шагов: 1. Загрузить массив а г ray [i ]. 2. Выполнить сложение total = total + array[i]. 3. Инкрементировать счетчик i цикла. 4. Сравнить счетчик цикла по условию <1000. 5. Перейти к шагу 1, если условие <1000 истинно. Если рассматривать только зависимости по данным, то эти пять шагов можно выполнить за три такта, поскольку загрузка массива на шаге 1 и инкрементиро- вание счетчика цикла на шаге 3 могут выполняться одновременно (так же как Шаги 2 и 4). Но вследствие латентности загрузки из памяти выполнение этого
100 Глава 8 • Память цикла займет гораздо больше времени, чем 3 х 1000 тактов. До сложения во второй строке должна закончиться загрузка из памяти в первой строке. Пока процессор ждет загрузки, он может забежать вперед и заняться другими независимыми вещами, такими как инкрементирование счетчика цикла, выполнение сравнения, предсказание возможного варианта перехода и переход назад в начало цикла, где он опять встретится со следующей загрузкой из памяти. И опять, во время ожидания теперь уже обеих загрузок (первой и второй) процессор может продолжить заниматься независимыми вещами, такими как инкрементирование, сравнение и т. д. В конечном итоге процессор обработает столько кода, сколько сможет, и ему придется просто ждать память. Латентность памяти может добавить ко времени выполнения команды сотни тактов. К сожалению, быстродействие процессора и потребность в памяти (источниками которой являются несколько ядер и все более сложные приложения) растут гораздо быстрее, чем производительность памяти, поэтому проблемы памяти обостряются. По мере повышения быстродействия процессоров (при постоянном быстродействии памяти) растет выраженная в тактах процессора латентность памяти, в результате все больше тактов процессора тратится на ожидание доступа к памяти. Буферы, кэши, внеочередное выполнение, команды подсказок кэша, автоматическая предвыборка и прочие функциональные возможности процессора — все вместе они призваны обеспечить рост производительности памяти и снижение времени ожидания доступа к памяти. Однако еще большую роль в этом способны сыграть правильные подходы к разработке и реализации программного обеспечения. Основы оптимизации памяти Оптимизация памяти в первую очередь касается кэша уровня 1 и буферов записи, поскольку все операции с памятью идут через эти точки. На рис. 8.1 показана блок- схема системы памяти компьютера с процессором Pentium 4. ! Кэш Мпу ! уровня 1 Буфера записи Кэш уровня 2 Шин W W Видеокарта aAGP Контроллер памяти Память видеобуфера Шина PCI рас Основная память Рис. 8.1. Блок-схема системы памяти для процессора Pentium 4
Основы оптимизации памяти 101 Основная и виртуальная память Объем памяти, которую может использовать программа, ограничен размером адресного пространства процессора. Для процессора Pentium 4 максимальное адресное пространство составляет 4 Гбайт и состоит из физической (обычно 512 Мбайт или более) и виртуальной памяти (остальное). Процессоры, созданные по технологии Intel EM64T, имеют виртуальное адресное пространство размером 264 байт. Доступные в настоящее время платформы, созданные по технологии Intel EM64T, поддерживают до 64 Гбайт физической памяти, и этот объем в будущем, вероятно, будет увеличиваться. Приложение использует виртуальную память косвенно, запрашивая больше памяти, чем имеется физически. Когда операционная система видит, что физической памяти больше нет, генерируется ошибка отсутствия страницы, после чего страница физической памяти сохраняется на жестком диске, освобождая память для приложения и создавая иллюзию почти неограниченного объема физической памяти. К сожалению, эта иллюзия стоит больших потерь производительности, поскольку подкачка памяти с жесткого диска и обратно занимает очень много времени. По этой причине очень важно тщательно планировать, сколько памяти потребует ваше приложение, чтобы быть уверенным в том, что ему хватит некоторого умеренного объема физической памяти (для минимизации подкачки страниц). Кэши процессора Для сокращения латентности физической памяти используются небольшие высокоскоростные области памяти, называемые кэшами. Процессоры Pentium M и Pentium 4 всегда имеют два кэша, называемые кэшами уровней 1 и 2, а некоторые процессоры (обычно на серверах) могут иметь и кэш уровня 3. Кэш уровня 1 используется на процессоре Pentium 4 только для данных (для команд применяется кэш трасс) — он маленький, но очень быстрый. Процессор Pentium M имеет кэш уровня 1 для данных и отдельный кэш уровня 1 для команд. На обоих процессорах (Pentium 4 и Pentium M) кэш уровня 2 и необязательный кэш уровня 3 являются объединенными кэшами (то есть содержат как данные, так и команды). Кэш уровня 2 гораздо больше кэша уровня 1, но медленнее. Кэш уровня 3 больше кэша уровня 2, то также медленнее. И наконец, основная память еще больше (обычно более 256 Мбайт), но она более чем в десять раз медленнее кэша уровня 1. В табл. 8.1 представлены относительные размеры и скорости кэшей и основной памяти для разных процессоров (через косую черту перечислены значения объема и латентности операций с целыми числами и плавающей точкой). В таблице не приведены значения быстродействия памяти, поскольку они значительно изменяются в зависимости от скорости системной шины, скорости самой памяти и ее объема. Например, минимальное значение латентности памяти Для процессора Intel Xeon MP составляет около 800 тактов (при незагруженной системной шине). В среднем значения латентности будут гораздо больше, поскольку системная шина часто загружена, и поэтому запрос к памяти должен ждать
102 Глава 8 • Память завершения обработки предыдущих запросов шины. В данном контексте даже самые медленные кэши имеют скорость от 5 до 10 раз более высокую, чем скорость основной памяти, а кэши уровня 2 примерно в 100 раз быстрее основной памяти. Такое огромное несоответствие в скоростях должно натолкнуть вас на мысль, насколько важным может быть эффективное использование кэшей процессора. Таблица 8.1. Относительные размеры и скорости кэшей и основной памяти Процессор Pentium 4, техпроцесс 130 нм, технология гиперпоточности Процессор Intel Xeon, техпроцесс 130 нм Процессор Pentium 4 (670), технология гиперпоточности Процессор Intel Xeon MP, техпроцесс 90 нм, технология ЕМ64Т Процессор Pentium M (780) Кэш уровня 1 8 Кбайт/ 2/9 8 Кбайт/ 2/9 16 Кбайт/ 4/12 16 Кбайт/ 4/12 32 Кбайт/ 3/3 Кэш уровня 2 512 Кбайт/ 7/7 512 Кбайт/ 7/7 2 Мбайт/ 18/18 1 Мбайт/ 18/18 2 Мбайт/ 9/9 Кэш уровня 3 1 Мбайт, 2 Мбайт/ 14/14 — 4 Мбайт, 8 Мбайт/ 162/162 - Основная память 128 Мбайт- 4 Гбайт 128 Мбайт- 4 Гбайт 128 Мбайт- 4 Гбайт 256 Мбайт- 64 Гбайт 128 Мбайт- 4 Гбайт Когда приложение обращается к области памяти (независимо от того, читаются данные или пишутся), процессор, прежде всего, ищет данные в кэше. Если данные уже находятся в кэше, говорят, что имеет место попадание кэша, и производится обращение к данным в кэше (а не к основной памяти). Когда запрошенных данных в кэше нет, говорят, что имеет место промах кэша, а это означает, что данные не- обходимо загрузить из основной памяти или из кэша более высокого уровня. При этом данные из памяти загружаются только в том случае, если их нет ни в одном из кэшей. При обращении к основной памяти процессор читает в кэш блок размером 64 байт, полностью заполняя одну строку кэша. То есть 64-байтные строки кэша выровнены по 64-байтным границам. Поэтому ссылка на байт 70 вызовет загрузку байтов 64-127. Кэши работают по принципам пространственной и временной локальности. Пространственная локальность проявляется тогда, когда совместно используются области памяти, находящиеся рядом друг с другом. Обычно программа обращается к памяти не случайным образом. Когда приложение обращается к байту х, то еле- J дующее обращение будет, скорее всего, к байту х + 1 и т. д. Поэтому, когда происходит промах кэша, приложение выбирает из основной памяти не один байт. Такая расширенная выборка повышает производительность, поскольку одна транзакция
Основы оптимизации памяти 103 в области памяти объемом 64 байт выполняется гораздо быстрее, чем 64 транзакции по 1 байт каждая. Временная локальность проявляется тогда, когда производится обращение к той области памяти, к которой недавно уже производилось обращение. Приложения имеют тенденцию повторно обращаться к одним и тем же областям памяти. Когда в кэш считываются новые данные, то им приходится замещать часть данных, которые уже находятся в кэше. Кэши замещают новыми данными те элементы, которые дольше всего не использовались (Least Recently Used, LRU). Это не то же самое, что частота применения — количество обращений к данной области памяти на работу кэша не влияет. Механизм кэширования Кэш уровня 1 является основным при анализе памяти и повышении производительности, поскольку почти вся память, используемая приложением, проходит через кэш уровня 1. Совершенствование работы кэша уровня 1 способно улучшить также работу кэшей уровней 2 и 3, а кроме того, сократить интенсивность подкачки страниц памяти операционной системой. Кэши данных уровня 1 на процессорах Pentium 4 и Pentium M организованы блоками по 64 байт, называемыми строками кэша; кэш может хранить 256 или 512 строк (16 384 или 32 768 байт). Группы по 8 строк называются сегментами, а столбцы из 32 или 64 строк — каналами. На рис. 8.2 показаны строки, столбцы, сегменты и каналы кэша уровня 1. Каналы 01234567 0J 1 | | | | | I - ill I I 1 1 J 1 I 2 i 31111111 Л^- 62 63 Рис. 8.2. Строки, столбцы, сегменты и каналы кэша уровня 1 Каждый столбец предназначен для хранения уникального 2- или 4-килобайтного блока памяти (в зависимости от количества столбцов и адреса памяти); это нужно Для ускорения времени, которое необходимо для определения факта попадания кэша. Если бы во всех 256 строках могла содержаться любая область памяти, I 1 сегмент равен 8 строкам J в одном ряду 1 строка равна 64 байта
104 Глава 8 • Память то процессору приходилось бы проверять все 256 строк, чтобы определить факт попадания кэша. Но с учетом присваивания каждому столбцу конкретного диапазона возможных адресов, процессору достаточно проверять только восемь строк для определения факта попадания кэша. Такая организация выравнивает кэш данных уровня 1 по 2- или 4-килобайтным границам. В процессоре Pentium 4 доступ к кэшу уровня 1 производится по линейному адресу, что дает программисту возможность определить, не происходят ли в кэше уровня 1 конфликты. В процессоре Pentium M кэш уровня 1 адресуется физическим адресом, поэтому места возможных конфликтов кэша определяются политикой операционной системы по установлению соответствия между страницами памяти. Обычно операционная система организует страницы в физической памяти таким образом, что любые попытки программиста минимизировать конфликты кэша на основе линейного адреса ведут также к минимизации конфликтов кэша с физическими адресами (для кэша уровня 1). Кэш уровня 2 на процессоре Pentium 4 имеет 8 каналов, 128 байт на строку и 1024 столбцов — итого 1 Мбайт. Кэш уровня 2 на процессоре Pentium M также имеет 8 каналов, но использует 64 байта на строку и 4096 столбцов — итого 2 Мбайт. Для процессора Pentium 4 кэш уровня 2 выровнен по границе 128 Кбайт, а для процессора Pentium M — по границе 256 Кбайт. Для обоих процессоров кэши уровней 2 и 3 адресуются по физическому адресу памяти. Поэтому конфликты в кэшах уровней 2 и 3 не могут контролироваться программистом непосредственно. Наоборот, на конфликты в кэше уровня 2 влияет политика операционной системы по установлению соответствия между страницами памяти, что осложняет программисту управление конфликтами кэшей уровней 2 и 3. Точную организацию кэша процессора можно определить программно, если процессор поддерживает возможность получить страницу детерминистских параметров кэша командой CPU ID. В этом случае программное обеспечение может использовать интерфейс запросов команды CPU ID для поиска информации о кэше каждого уровня. Имеется информация о размере, количестве каналов, количестве сегментов и о том, сколько логических процессоров совместно используют кэш данного уровня. Дополнительную информацию по конкретным способам запроса этой информации смотрите в главе 6 руководства по оптимизации [17]. С помощью этих запросов программное обеспечение может автоматически подстроиться к системам кэшей, имеющимся на различных реализациях процессоров. Например, быстрые реализации команд memcpy и memset в компиляторах C++ и Fortran производства Intel применяют этот механизм для принятия решения о том, следует ли задействовать потоковые операции сохранения или нет (опираясь при расчете на то, какая часть кэша будет перезаписана вызовом memset или memcpy). Аппаратная предвыборка Оба процессора — Pentium 4 и Pentium М — поддерживают аппаратную предвыбор- ку данных. Аппаратно можно обнаружить промахи кэша самого высокого уровня и запустить предвыборку, когда разность адресов по двум последовательным промахам находится в определенных пределах (128 или 256 байт). Дополнительно для
Основы оптимизации памяти 105 запуска предвыборки распознается шаговый доступ как вверх, так и вниз по адресам памяти. Блоки предвыборки могут поддерживать от 8 до 12 отдельных потоков данных. Все потоки должны выбираться из разных 4-килобайтных страниц, и при предвыборке нельзя пересекать границу 4-килобайтной страницы. Поэтому аппаратная предвыборка наиболее эффективна при шаговом доступе с малыми шагами, а также когда к потоку данных производится много обращений в пределах одной 4-килобайтной страницы. В главе 9 объясняются способы преобразования циклов, позволяющие добиться в циклах шагового доступа с малыми шагами. Программная предвыборка Команды программной предвыборки поддерживаются в процессорах производства Intel, начиная с Pentium III. С появлением аппаратной предвыборки в процессоре Pentium 4 команды программной предвыборки перестали считаться столь эффективными, поскольку аппаратная предвыборка в большинстве случаев работает с меньшими издержками. Однако несмотря на то, что аппаратная предвыборка во многих случаях эффективнее, все же есть некоторые области, в которых для повышения производительности можно задействовать программную предвыборку более эффективно, чем аппаратную. Команды программной предвыборки наиболее полезны тогда, когда в коде больше потоков данных, чем могут обработать аппаратные блоки предвыборки, когда шаги при шаговом доступе больше примерно одного килобайта, и когда процессор способен делать с текущими данными достаточно много вычислений, чтобы позволить обращениям к памяти, вызванным командами программной предвыборки, выполняться параллельно с вычислениями. В табл. 8.2 представлены различные формы программных команд предвыборки, которые поддерживаются процессорами Pentium M и Pentium 4. Таблица 8.2. Четыре разных типа команд программной предвыборки Команда ассемблера PREFETCHNTA PREFETCHTO PREFETCHT1 PREFETCHT2 Внутренний тип компилятора C++, используемый как второй параметр в команде _mm_prefetch(char *p, int Hint) _MM_HINT_NTA _MM_HINT_T0 _MM_HINT_T1 _MM_HINT_T2 Описание Предвыборка в буфер прямого доступа к памяти; полезна для данных, читаемых только один раз Предвыборка данных во все кэши; полезна для чтения и/или записи данных Предвыборка данных в кэши уровней 2 и 3, но не 1 Предвыборка данных только в кэш уровня 3
106 Глава 8 • Память Учтите, что у вас есть четыре разных типа команд предвыборки, причем в каждом процессоре они могут реализоваться несколько по-разному (поскольку команды предвыборки являются просто «подсказками»). В процессоре Pentium 4 команда PREFETCHNTA минует кэш уровня 1 и делает выборку данных в тот жестко закодированный канал сегмента в кэше уровня 2, который связан с выбираемой памятью. В процессоре Pentium M та же самая предвыборка обеспечивает только выборку данных в кэш уровня 1 и делает это таким образом, что следующее обращение тому же сегменту в кэше уровня 1 вытесняет считанные при предвыборке данные. Команду PREFETCHTO эти два процессора также реализуют несколько по- разному. В процессоре Pentium 4 она обходит кэш уровня 1, но доставляет данные обычным образом в кэш уровня 2. В процессоре Pentium M выбранные данные доставляются в кэши уровней 1 и 2 обычным образом. В обоих процессорах команды PREFETCHT1 и PREFETCHT2 обходят кэш уровня 1 и доставляют данные в кэш уровня 2 обычным образом. Запись данных без использования кэшей — прямая запись За некоторыми исключениями, все операции чтения и записи проходят через кэи уровня 1, что в большинстве случаев является желательным. Однако в некоторых случаях кэширование записи может отрицательно повлиять на производительность и функциональные возможности таких устройств, как регистры управленк на картах расширения и прочие аппаратные буферы. По этой причине операционная система и драйверы устройств могут объявить некоторые области памяти не- кэшируемыми. Некэшируемые данные записываются в память немедленно и точнс в указанном порядке, причем минуя кэш. К сожалению, некэшируемая память имеет очень низкую производительность, поскольку каждый элемент данных приходится записывать при помощи отдельной транзакции по шине памяти. Похожий тип памяти (но имеющий гораздо более высокую производительность) называется некэшируемой памятью с объединенной записью, или простс памятью с объединением записи (Write Combining, WC). WC-память используется тогда, когда очередность и срочность записи не важны, а важна произвс дительность. WC-память перед запуском одной большой транзакции, предназначенной для сохранения в основной памяти всего блока, задействует внутренние буферы памяти, чтобы сохранять непрерывные последовательности операций записи в память. Операционная система и драйверы устройств могут назначать областям памяти тип WC. Этот тип памяти обычно служит для таких устройств, как видеобуфер графических адаптеров и буферы контроллеров жестких дисков (поскольку очередность и срочность записи данных в этих случаях не важны, к тому же, процессору эти данные повторно не понадобятся). Для немедленного сброса буферов записи непосредственно перед тем, как данные понадобятся, можно задействовать специальную команду SFENCE, например, перед перерисовкой дисплея. Помимо аппаратной записи приложения могут использовать буферы WC-na- мяти, чтобы обойти запись данных в кэш, когда данные требуется только запи-
Основы оптимизации памяти 107 сать в память, и обход кэша в этой ситуации может дать повышение производительности. Приложение может задействовать буферы WC-памяти путем записи данных с помощью одной из команд потокового сохранения. Когда процессор выполняет команду потоковой записи, используются буферы записи, и кэш обходится. Если в кэше уже имеются данные, то они либо вытесняются, либо делаются недействительными. Команды потоковой записи могут записывать 32-, 64- и 128-разрядные переменные и доступны из ассемблера в виде внутренних команд компилятора и из библиотек классов C++, поставляемых с компилятором C++ производства Intel. В табл. 8.3 перечислены доступные команды потоковой записи. Таблица 8.3. Потоковые команды (команды прямой записи) процессора Pentium 4 Тип данных Целое двойное слово (32 бит) Целое учетверенное слово (64 бит) Целое двойное учетверенное слово (128 бит) Два значения с плавающей точкой двойной точности (128 бит) Четыре значения с плавающей точкой одинарной точности (128 бит) Выбранные байты учетверенного слова (64 бит) Выбранные байты двойного учетверенного слова (128 бит) Внутренняя команда _mm_stream_si32 _mm_stream_pi _mm_stream_si 128 _mm_stream_pd _mm_stream_ps _mm_maskmove_si64 mmmaskmoveusi 128 Команда ассемблера MOVNTI MOVNTQ MOVNTDQ MOVNTPD MOVNTPS MASKMOVQ MASKMOVDQU Следующий код записывает 64 байта с использованием команд потоковой записи: // ассемблер movntdq mem, xmmO // внутренные команды компилятора jnm_stream_si128(mem, a); // библиотека классов C++ store_nta(mem, a); # Единственное требование для максимальной производительности — запись в память должна выполняться без пропуска байтов и должны соблюдаться правила
108 Глава 8 • Память выравнивания данных. Для нахождения тех мест, где это требование не выполняется, можно использовать счетчики событий Write WC Full и Write WC Partial процессора. Проблемы памяти, влияющие на производительность В идеальном случае память была бы столь же быстрой, как и процессор, и вам не пришлось бы иметь дела с кэшами и всеми сопутствующим проблемами. К сожалению, оборотной стороной такой системы была бы дороговизна компонентов памяти. Поэтому на программисте по-прежнему лежит ответственность за то, чтобы избегать в своих приложениях побочных эффектов кэширования (сохраняя, однако, максимальную производительность). Принудительная загрузка кэша Три вещи приводят к загрузке данных в кэш — это промахи кэша, связанные с принудительной загрузкой, конфликтами и емкостью кэша. Принудительная загрузка кэша происходит тогда, когда данные загружаются в первый раз. Поскольку этих данных в кэше никогда не было, то процессор должен загрузить их принудительно Количество операций принудительной загрузки можно сократить, но полность] их избежать нельзя. Количество операций принудительной загрузки определяется объемом памяти, используемым приложением. Минимальное количество операций принудительной загрузки кэша определяется общим количеством уникальных байтов памяти, требуемых приложению, разделенным на количество байтов в строке кэша. Поскольку очень трудно подсчитать весь объем памяти программы (из-за вызовов функций, работе со стеком, выполнения заданий операционной системы и т. д.), то такое вычисление обычно имеет смысл только для небольшой части приложения (такой как цикл или короткая функция). Модификация приложения таким образом, чтобы оно обращалось к меньшему объему памяти — только один из способов снижения количества промахов кэша, связанных с принудительной загрузкой. Более важным, чем количество промахов кэша, связанных с принудительной загрузкой, является количество потерянного при этом времени. Поскольку процессор выполняет команды не по порядку, то промахи кэша не обязательно приводят к потерям производительности. Производительность теряется только когда процессор не может выполнять никаких других команд и вынужден ждать доступа к памяти. Возможно, производительность будет ограничена из-за связанных с принудительной загрузкой промахов кэша в тех местах приложения, где имеется большое количество зависимостей по даннмм и расходуется большой объем памяти.
Проблемы памяти, влияющие на производительность 109 Используя инструменты наподобие анализатора VTune, вы можете обнаружить, где происходят промахи кэша, а эксперименты с производительностью помогут вам определить, сколько времени из-за них теряется. Загрузка из-за недостаточной емкости кэша Емкость кэша — это еще одна причина промахов кэша. Загрузка кэша из-за его недостаточной емкости имеет место тогда, когда данные, которые уже были в кэше, загружаются повторно. Если бы у процессора был больший кэш (в нем было больше строк или каждая строка была бы больше), то операций загрузки из-за недостаточной емкости кэша можно было бы избежать (поскольку данные могли бы остаться в кэше). Количество операций загрузки из-за недостаточной емкости кэша можно снизить путем модификации алгоритмов (чтобы использовать в них меньше данных). Вместо того чтобы работать с блоком данных, слишком большим, чтобы поместиться в кэш, обычно лучше обрабатывать более мелкие блоки данных, которые помещаются в кэш. Такая оптимизация называется вскрытием недр, или разделением на блоки. Так же как и во всех операциях загрузки кэша, количество операций не так важно, как количество потерянного времени. Загрузка из-за конфликтов кэша Конфликтующие адреса, источником которых является способ организации кэша, вызывают дополнительные операции загрузки кэша. Загрузка из-за конфликтов кэша происходит потому, что каждый столбец кэша может содержать только определенные адреса памяти. Если в коде происходит обращение к девяти или более элементам данных в одном и том же столбце, но в разных строках кэша, то все они не могут поместиться в кэше, поскольку один столбец кэша может содержать максимум восемь строк. Например, рассмотрим алгоритм обработки изображения, который складывает два плоских изображения в одно сжатое (как показано на рис. 8.3). Алгоритму необходимо 8 отдельных указателей чтения (по одному на каждый канал цветности) и 1 указатель записи в место назначения. Конфликты кэша происходят, когда растровые изображения выровнены по 2-килобайтным границам, поскольку восемь указателей всегда конкурируют за один столбец кэша, а для девятого указателя конфликт кэша имеет место тогда, когда целевое растровое изображение оказывается выровненным аналогично. Вы можете избежать конфликтов кэша, изменив выравнивание памяти, храня данные в регистрах или используя алгоритм, обращающийся к меньшему количеству областей памяти. Если бы на рис. 8.2 буферы были выровнены по разным 128-байтным границам (выравнивание по 128-байтным границам помогает также избежать промахов из-за конфликтов кэша уровня 2), то конфликтов кэша не происходило бы, и производительность была бы более чем в два раза выше.
110 Глава 8 • Память Пикселы всех четырех каналов ■I II Рис. 8.3. Пример преобразования данных плоского изображения в сжатые данные Выравнивание по 64-килобайтным границам в ранних версиях процессора Pentium 4 также может вызвать конфликты. Во избежание этих конфликтов избегайте одновременного использования двух или более буферов, выровненных по 64-килобайтным границам. Для выявления этой проблемы служит счетчик событий 64К Aliasing Conflicts. В более поздних версиях процессоров Pentium 4 этот конфликт происходит на 4-мегабайтных границах, но для проверки этой ситуации можно задействовать этот же самый счетчик событий. Эффективность кэша Эффективность кэша — это отношение объема загруженной в кэш памяти к объему фактически расходуемой памяти. На эффективность кэша влияют две вещи: сколько байтов на строку кэша используется и сколько раз загружается одна и та же строка кэша. Например, если алгоритм обращается к каждому второму элементу в массиве чисел, то время на загрузку неиспользуемых элементов расходуется впустую, так как кэш всегда загружает всю строку кэша целиком (даже если нужен всего 1 байт). Конфликты кэша и операции загрузки кэша из-за его недостаточной емкости также снижают эффективность кэша. Если вследствие конфликта или загрузки из-за недостаточной емкости кэша надо повторно загрузить ту же строку кэша, то количество передаваемых данных удваивается, и эффективность кэша падает вдвое. Плохо организованные структуры данных могут также привести к низкой эффективности кэша. Важно организовать структуры данных таким образом, чтобы элементы, которые используются вместе, находились рядом друг с другом, что приведет к их попаданию в одну и ту же строку кэша.
Проблемы памяти, влияющие на производительность 111 Опережающая запись Когда процессор выполняет команду записи, выделяется память для буфера записи. После выполнения команды записи буфер записи содержит значение, которое команда записи сохраняет в памяти, а также адрес памяти. Операциям загрузки, которые происходят после записи, часто даже не нужно ждать удаления команды записи перед тем, как начать выполняться. Если загрузка происходит из неперекрывающейся области памяти, то запись не влияет на загрузку, и она может выполняться. Если загрузка полностью перекрывается записью, то значение из операции записи иногда можно с опережением передать в операцию загрузки, что избавляет от необходимости дожидаться удаления команды записи и сохранения ее данных в кэшах. В табл. 8.4 показаны условия, при которых операции загрузки могут получить значения, переданные им с опережением из предыдущих операций записи. Возьмем шестую строку в качестве примера того, как надо читать эту таблицу. В этой строке показано, что для операции записи 8 или 16 байт и загрузки одного байта значение операции записи может быть с опережением передано в операцию загрузки только в том случае, если операция записи выровнена и если адрес загрузки на 0, 1, 2, 3 или 4 байт больше, чем адрес записи. Та же строка показывает, что для невыровненной записи значение из операции сохранения может быть передано с опережением только в операцию загрузки одного байта с точно таким же адресом, что и у самой операции записи. Таблица 8.4. Передача с опережением из операции записи в операцию загрузки в процессоре Pentium 4 Размер записи в байтах 1,2,4,8 16 [г 1 4 L4 1а16 [8,16 1а16 [i6 Размер загрузки в байтах Такой же, как у сохранения Такой же, как у сохранения 1 1 2 1 2 4 8 Смещение в байтах для загрузки, с опережением получающей значения от выровненной записи 0 0 0,1 0,1,2,3 0,1,2 0,1,2,3,4 0,1,2,4 0,4 0 Смещение в байтах для загрузки, с опережением получающей значения от невыровненной записи 0 0 0 0 0 0 0 0 0 J
112 Глава 8 • Память Когда загрузка частично перекрывается записью или перекрывается полностью, но без получения значений с опережением, тогда она должна ждать удаления команды записи и сохранения данных в кэше перед тем, как можно будет выполнить загрузку. Иногда латентность формирования адреса для загрузки или записи заставит процессор предположить, что загрузка может перекрыть запись. Такие проблемы опережающей записи могут серьезно сказаться на производительности, и их следует избегать. Компиляторы производства Intel, когда встречают такую ситуацию, пытаются избежать этих проблем, но часто компилятор не знает, что существует проблема опережающей записи. Проблемы опережающей записи проще всего обнаружить в анализаторе VTune при помощи событий MOB Loads Repla Retired для процессора Pentium 4 и Resource Related Stalls для процессора Pentium M. С процессором Pentium M необходимо быть внимательнее, так как счетчик Resource Related Stalls анализатора VTune считает не только события опережающей записи, но и другие события. Если это событие происходит очень часто, ищите в ближайших командах проблемы опережающей записи. Если вы не нашли таких проблем. это означает, что событие могло быть вызвано либо какой-то другой проблемой связанной с ресурсами (такой как нехватка буферов загрузки или сохранения) либо простым превышением максимального количества микроопераций, которы могут быть обработаны компьютером одновременно. Выравнивание данных Невыровненные данные по нескольким причинам могут стать основной «головно болью» для процессора. Если переменная распределена на две строки кэша, то не обходимо обращаться к обеим строкам, что вдвое снижает производительности памяти. Кроме того, в конечном итого процессору нужно объединить эти две по ловины для того, чтобы получить значение переменной, а на это потребуется ещ больше времени. Например, попытка прочитать переменную размером в двойно слово (четыре байта) с адреса 126 приведет именно к таким издержкам. Два байт находятся в одной строке кэша, а два — в другой. Другая проблема, связанная с невыровненными данными, состоит в том, чт невыровненные записи не могут с опережением пересылаться в последующие загрузки так же часто, как выровненные. Эта проблема опережающей записи може вызвать увеличение количества загрузок, ожидающих записи в кэш перед тем, ка команды смогут выполняться, и это ожидание в свою очередь может затормозит процессор. И, наконец, выравнивание данных влияет еще и на доступные команды поскольку некоторые SIMD-команды требуют выровненных данных, а на невы ровненных приводят к сбоям. Один из самых простых способов повысить производительность — обеспечит выравнивание данных. В табл. 8.5 приведены правильные варианты выравнивани для различных типов данных. Обращение к тем невыровненным данным, которые не разделены между разны ми строками кэша уровня 1, не приводит к снижению производительности. Однак многие SSE-, SSE2- и SSES-команды требуют выровненных в памяти операндо
Проблемы памяти, влияющие на производительность 113 и при использовании с невыровненными адресами памяти вызывают сбои. Очень немногие SSE-, SSE2- и SSES-команды не требуют выровненных операндов, в их число входят команды MOVUPS, MOVUPD, MOVDQU, MOVSD, MOVSS, MOVHPD, MOVLPD, M0VHPS, MOVLPS, M0VDDUP и LDDQU. Таблица 8.5. Правила выравнивания данных Тип данных Байт (1 байт, или 8 бит) Слово (2 байта, или 16 бит) Двойное слово (4 байта, или 32 бита) Учетверенное слово (8 байт, или 64 бита) Двойное расширенное значение с плавающей точкой (10 байт, или 80 бит) 16 байт, или 128 бит Выравнивание Любое 2-байтное 4-байтное 8-байтное 8- или 16-байтное 16-байтное Невыровненные данные можно обнаружить двумя способами: □ Доступ к невыровненным данным, которые распределены между строками кэша уровня 1, называются разделенными загрузками или разделенными записями. В анализаторе VTune имеется два счетчика событий, которые обнаруживают разделенные обращения: Split Loads Retired и Split Stores Retired. а Невыровненные данные, обращение к которым происходит с использованием SSE-, SSE2- или SSES-команд (для этих команд требуется выровненная память), вызывают необработанное исключение (как показано на рис. 8.4). Будьте внимательны, в сообщении об ошибке не говорится ничего конкретного о том, что его причиной являются невыровненные данные. Рис. 8.4. Попытка использовать 55Е2-команды для обращения к невыровненным данным Компиляторы и выравнивание данных Для того чтобы получить правильную программу, компиляторы следуют правилам, основанным на стандартах языков программирования и требованиях
114 Глава 8 • Память операционной системы к выравниванию данных. Однако законный способ может не быть самым эффективным. Например, в системе Linux на процессорах семейства IA-32 в соответствии с требованиями Linux ABI тип doubl e должен быть выровнен только по 4-байтной границе. Таким образом, например, в следующем массиве структур член т2 правильно выровнен только в нечетных элементах массива: struct poor_align { int ml; double m2; } аггСЮОО]; В четных элементах массива член т2 выровнен только по 4-байтной границе (а не по 8-байтной), в результате может пострадать производительность. Этого неправильного выравнивания можно избежать, сделав размер массива struct кратным 8 байтам и выровняв член т2 по 8-байтной границе. Когда это допустимо, компилятор производства Intel часто увеличивает величину выравнивания переменной, чтобы добиться роста производительности. Проблемы с выравниванием данных могут происходить и при приведении типов переменных, как показано в следующих примерах. // возвращаемое из malloc значение приводится к типу double // это может означать проблему выравнивания double* pDblArray = (double*)malloc (48*sizeof(double)); // тип указателя на число с плавающей точкой приводится // к указателю на F32vec4 (4 числа с плавающей точкой) // это может означать проблему выравнивания float Array0fFloats[128]; _ml28 * pSIMDFloats = (_ml28 *)ArrayOfFloats; В первом случае возвращаемое из функции та 11 ОС значение не гарантирует возврата буфера, выровненного по 8-байтной границе. Если он будет выровнен только по 4-байтной границе, каждое значение двойной длины в этом объекте окажется выровненным плохо. Во втором случае компилятор мог не выровнять массив ArrayOfFloats по 16-байтной границе (поскольку тип float должен быть выровнен только по 4-байтной границе). Однако тип данных т128 должен быть выровнен по 16-байтной границе, так что в зависимости от того, как используется переменная pSIMDFloats, этот код может иметь проблемы производительности или вызывать исключение (при использовании с командой, требующей операнда, выровненного в памяти по 16-байтной границе). Программная предвыборка Целью оптимизации памяти является максимально быстрое обращение к минимально необходимому объему памяти, что требует внимательной компоновки
Проблемы памяти, влияющие на производительность 115 структур данных и буферов памяти для увеличения доли попаданий кэша. Но когда данные находятся не в кэше, то для снижения времени ожидания доступа к памяти можно использовать способность процессора к предвыборке. Команда предвыборки говорит процессору, что приложение собирается использовать определенную область памяти, и поэтому процессор должен подготовить ее, начав операцию загрузки. Когда пропускная способность шины позволяет, процессор начинает загрузку содержимого памяти в кэш (еще до того, как это содержимое понадобится). К тому времени, как содержимое памяти действительно понадобится, данные должны быть в в кэше или, по крайней мере, на пути туда. Для указания, какой кэш (или кэши) необходимо загрузить, существуют четыре типа команд предвыборки. Предвыборку в буфер прямого доступа следует использовать тогда, когда приложение читает данные ровно один раз. Если алгоритм обновляет область памяти путем чтения-модификации-записи либо как-то иначе обращается к данным более одного раза, то необходимо задействовать команду- подсказку _ММ_Н I NT_T0. Команда предвыборки работает лучше всего тогда, когда загружает данные заранее с достаточно большим запасом по времени для того, чтобы содержимое памяти было в кэше тогда, когда оно понадобится. Насколько заранее — зависит от многих вещей, но примерно 100 тактов времени выполнения является хорошей начальной точкой. Часто можно очень легко запрограммировать предвыборку данных для будущей итерации цикла, что может дать хорошие результаты. Иногда обстоятельства требуют предвыборки за две, четыре или даже более итераций цикла для достижения максимальной производительности предвыборки. Необходимо немного поэкспериментировать, чтобы определить, где лучше всего реализовать предвыборку и какие именно данные выбирать. Но учтите, что поскольку память, контроллеры памяти и скорости шин меняются, то и наилучшее местоположение команды предвыборки тоже может меняться. Команда предвыборки загружает строку кэша целиком, поэтому достаточно выполнить предвыборку одного байта из каждых 64. Добавление слишком большого количества команд предвыборки может снизить производительность. Следующий образец кода выдает команды предвыборки данных заранее за 16 итераций цикла и делает только одну предвыборку каждые 4 итерации. Таким образом, код не порождает слишком много программных предвыборок. Этот пример иллюстрирует также ситуацию, когда программная предвыборка может быть эффективной, поскольку шаг достаточно велик для того, чтобы аппаратная предвыборка не работала эффективно. for (1=0; 1<1 000 000; 1+= 512) { // предвыборка не дает сбоев из-за неверной памяти, поэтому // можно безопасно делать предвыборку в конце массива if ((i & 2047) == 0) { _mm_prefetch((char *)&array[i+2048]. _MM_HINT_T0); } x = fn(&array[i]); }
116 Глава 8 • Память Выявление проблем памяти Варианты оптимизации памяти основаны на точном определении местоположения проблемы в памяти и ее причин. Память может стать проблемой всегда, когда процессору приходится ждать ее данных, а такая ситуация часто вызывается подкачкой страниц или промахами кэша. Выявление таких мест, а затем и определение тех вариантов оптимизации, которые позволяют получить значительный положительный эффект, и является предметом данного раздела. Для того чтобы определиться с тем, чего нам ждать, очень полезно обдумать суть приложения. Сколько оно расходует памяти относительно общего объема физической памяти? Ожидаете ли вы постоянной подкачки страниц или она будет совсем небольшой и только на этапе инициализации? Тщательно ли спланированы структуры данных для минимизирования промахов кэша или они бессистемно перемешаны? Поиск случаев отсутствия страниц Подкачка страниц всегда является знаком того, что процессор ждет доступа к памяти, и такие ситуации должны быть устранены везде, где это возможно. Самым быстрым способом обнаружить подкачку страниц является выборка значений счетчика Pages/sec операционной системы для объекта Memory. При этом можно использовать либо анализатор VTune, либо монитор производительности от Microsoft (PERFMON.EXE). На рис. 8.5 показана выборка значений этого счетчика событий с помощью монитора производительности. Диаграмма на рис. 8.5 показывает, что система порождает большое количество случаев отсутствия страниц памяти. Поскольку на обработку каждого такого случая тратится много времени, важно сосредоточить усилия оптимизации на устранении (или по крайней мере минимизации) случаев отсутствия страниц. Помимо добавления памяти в компьютер, единственным способом избежать случаев отсутствия страниц является модификация приложения таким образом, чтобы оно расходовало меньше памяти или использовало ее по-другому (увеличивая локальность страниц и соответственно локальность кэша процессора). Вряд ли может быть сложно определить, какая часть приложения порождает случаи отсутствия страниц (путем изучения тех мест, где выделяются большие буферы и области памяти). Не забудьте рассмотреть и вызовы функций операционной системы или других приложений, которые могут вызывать случаи отсутствия страниц «от имени» вашего приложения. Случаи отсутствия страниц могут быть непостоянными; то есть когда ваше приложение будет выполняться в следующий раз, в памяти могут оказаться другие страницы, и профиль приложения будет выглядеть по-другому. Оснастка Counter Monitor анализатора VTune способна помочь выяснить, какой именно код выполнялся тогда, когда имели место случаи отсутствия страниц. На рис. 8.6 показано, что программа HUFF.EXE создает всплеск случаев отсутствия страниц, но только непосредственно при запуске — при выполнении кода инициализации.
Выявление проблем памяти 117 Ш File Action View Favortes Window Help Jsjx pi Console Root ;^ System Monitor - @ Performance Logs and Alens Щ Counter Logs Щ Trace Logs f] Alerts d □ li!9 gel +x 9 §tmm !Oj»Lf 122.823 Average Maximum 63.261 Minimum 416.401 Duration 0.000 1:40 1 Color [ Scale j Counter ) Instance jParent T Object Рис. 8.5. Монитора производительности отслеживает значения счетчика Pages/sec |gte go* »ew ftcttvity Configure ajndow fcjefc /| |£ ^ [£ £ % £< 'l!a U ' 4> <J5> ■ ^ • [a^1'(Court» Ь ^.Щ 3* :■& V* Ъ VTPrcject2 - Ф Activrtyl (Counter MonitOf) - £ЩВ@2ШёЗ Ш Court» Monitor Results (160) 8 §* SamplngResulU(161) Court» Morttor Logged date tor HUrTfXE 54.00 5100 48.00 45.00 42.00 3900 1 _ 36.00 133.00 & 30.00 I 27.00 i t 24.00 | 21.00 Q 18.00 15.00 12.00 900 6.00 200 400 600 800 1000 1200 1400 1600 1800 2000 2200 2400 2600 2800 3000 3200 3400 3600 3800 4000 Tins (hWseconds) ILl1. i'IJ!.1' .'il И'."'','" !l IIM.. I 'А ИМИ',.'. ''II. ft 1.' Jif!! !i^l И • Mwnory: Pages/soc 0.01 1889.800 0.000 5309.003 2502.578 Рис. 8.6. Выборка значений счетчика Pages/sec в оснастке Counter Monitor анализатора VTune
118 Глава 8 • Память ДНЯ MEfe Б* У**" 6ct"v*y iorfigure Ytfndow fcMp \ ?Р -Г £VTProiect2 - 4% Actrvilyl (Counter Monitor) - fc First Run (159) 3> Counter Monitor Results (160) * 3* Sampling Results (161) j£JXJ j£) £3 Щ] f£) D ■ 3 Process ~p Thread $) Module 5П (Module 1 ntoskmlexe MCSCAN32.DLL ntdLtJ ntoskrnl.exe halt* h«l.<* ntoskrnl.exe 1 ntoskrnl.exe ntoskmlexe ► huff.exe ттшш—шшшшш Event* T.,,, |V „.,1,,.. ,.,,,„, i-,1,-,-n„.M,J-i,...JQ 00 2jhuff.exe 1 80.00 10C jOockMcte 4,780,400,0001 V Events Total Clockticks samples(161) ' 2.812 00 Clockticks events(161) 4.780.400.000 00 Clockticks *(161) 7633 Evert ;Ac«vtylD Scale SampteAfter Value TotalSamples JDuation(t) iRr^0 Rr^: .- - Й * (3ockbcks 161 O.OOOOOOOIOOOx 1700000 3684 901 719 В 1ИЙЙМИМШЙ1 --^ __ Щ Рис. 8.7. Выборка значений Clocktick в то же время, которое показано на рис. 8.5 Более подробное изучение пика на графике показывает, что программа HUFF.EXE была единственным активным процессом (как показано на рис. 8.7). С учетом данной информации и знаний о том, как работает программа, можне прийти к выводу, что причиной отсутствия страниц, скорее всего, является загрузи файла несжатых данных. Поскольку после старта программы случаев отсутствия страниц не наблюдается, а загрузки файла данных не избежать, то данную программу нет смысла оптимизировать, во всяком случае, в отношении решения проблемы отсутствия страниц. Поиск проблем опережающей записи Опережающая запись нечасто является причиной проблем производительности. Однако если эти события происходят в программе часто, то они могут вызвать значительное падение производительности. Поэтому было бы хорошо исключить или исправить любые проблемы опережающей записи до начала работы над остальными обращениями к памяти. Самым простым способом поиска проблем опережающей записи является использование на процессоре Pentium 4 анализатора VTune с выборкой события MOB Loads Replays Retired. К сожалению, на процессоре Pentium M подобного события не существует, поэтому поиск проблем опережающей записи на этом процессоре сложнее. На рис. 8.8 показана выборка информации в анализаторе VTune для события MOB Loads Replays Retired, используемая для поиска возможных проблем опережающей записи в приложении HUFF.EXE. В данном
Выявление проблем памяти 119 случае весьма значительное количество событий MOB Loads Replays Retired обнаружено в функции Huff Compress. Однако более глубокий анализ показывает, что эти события связаны с противоречиями между адресами записи и загрузки, а не с несоответствием размеров или смещений (которые можно легко устранить). Кроме того, события Clocktick показывают, что функция Huf fCompress не является такой горячей точкой, как другие функции программы; то есть хотя при снижении количества событий MOB Loads Replays Retired можно ожидать некоторого роста производительности, данный фрагмент кода не кажется стоящим объектом для приложения сил. Рис. 8.8. События MOB Loads Replays Retired и Clocktick для программы HUFF.EXE Поиск промахов кэша уровня 1 Когда подкачка страниц взята под контроль, а простые улучшения, касающиеся опережающей записи, выполнены, наступает время сосредоточиться на кэше уровня 1. Через кэш идут все обращения к памяти, если не считать случаев объединения записи (WC) и некэшируемой памяти. Выборка по событию промаха кэша уровня 1 покажет те части приложения, которые обращаются к памяти или, по крайней мере, делают промахи кэша уровня 1. Сравнение этих точек с теми горячими точками приложения, в которых расходуется больше всего времени, покажет вам те точки, где процессор ожидает доступа к памяти. Вам следует изучить только точки, расходующие много времени и имеющие промахи кэша уровня 1.
120 Глава 8 • Память На процессоре Pentium 4 выборка значений счетчика 1st Level Cache Load Misses Retired в анализаторе VTune покажет вам те места, где происходят все промахи кэша уровня 1 (как показано на рис. 8.9 для приложения HUFF.EXE). При использовании анализатора VTune на процессоре Pentium M наилучшее приближение для числа промахов кэша уровня 1 даст выборка по значениям события LI Lines Allocated. д^|щ^ шшшашшшшшш шшшш :igurc Window ЦЫр в« ! *Й8 S3 ! ^> Ф j % ;|Activity 1 (Sampling) "3£> « H X j<S>4l¥ I * В ,3 VTPfoject8 Й- <% Activity 1 (Sampling) - :Jflt Sampling Result* {112} Гт! % Run 1 !*] Clockticks BlfcRun2 [Й 1st Level Cache Loaj u3 И I Vй Ф :ч Э I |в !HI * j Ш I Э Pf^ess §p Thread $1 Module ! J] Hotspot ® S»'» Group by jj£ Function Function _intel_new_memset void GetCode$(struct lagHuffCode ".unsigned]! vc^AppendBrts$f unsigned chat 'unsigned r int HuffCompress$(unsigned char * ".unsigned] Events Clockiicks$amples(112) 1st Level Cache Load Misses Retired Clocklicksevents{112) Clocklicks%(112) 1st Level Cache Load Misses Retired . 1 st Level Cache Load Misses Retired.. 1 st Level Cache Load Miss Performan.. 1 st Level Cache Load Misses Retired 4,600,000 -M* — -jjj zL Total 311.00 4600 1.057.4... 18.29 4.600.0... 92.00 4.35 2J mm Event Activity ID Scale Sample After Value Total Sample Clocktcks 112 0.ОЮ0О001 COOx 3400000 ~ 3849"" 1st Level Cache Load Misses Retired 112 0.00001 OOOOOOx 100000 132 1st Level Cache Load Miss Performance Impact 112 Ю.ОООООООООООх - J JtJ 4 Hems, 3 events, 1 item(s) selected. !Al Process ;АЙ Trread 1 Module Sampling Modules - {Sampling Results) ] Sampling Hotspots - [Sampling Results! j General m ~3 4 [Mon Sep 1217:57:48 2005 <localrost> (Run 2) The Sampling Cotector is cdtectiig samples based on the fotowing everts): Oocktickx Mon Sep 1217:57:51 2005 <bcaliost> (Run 2) Sampling: data was successfully collected. Mon Sep 1218:00:20 2005 <localhost> (Run 1) The Sampling Collector is collecting samples based on the following eventjs): Cfockticks. Mon Sep 1218:00:23 2006 <localiost> (Run 1) Samplrng data was successfully collected. IMon Sep 1218:00:24 2005 <locaSwst> (Run 2) The Sampling Collector is collecting samples based on the fofcwing events): 1 st Level Cache Load Misses Retired. (Mon Sep 1218:00:27 2005 <localhost> (Run 2) Sampling data was successfully collected Рис. 8.9. Промахи кэша уровня 1 и событие Clocktick для приложения HUFF.EXE Из рисунка видно, что горячие в смысле расходуемого времени точки приложения и точки промахов кэша уровня 1 никак не связаны. Большая часть промахов кэша уровня 1 происходит в функции Huf fCompress, но она не расходует большую часть времени. В то же время функция AppendBits расходует большую часть времени, но не в ней происходит основное количество промахов кэша. В данном случае усилия по оптимизации следует в первую очередь сосредоточить на тех функциях, которые расходуют большую часть времени. Таким образом, очередность оптимизации должна быть следующей: AppendBits, GetCode, Huf fCompress. Поскольку только в трех функциях происходят промахи кэша уровня 1, и все они относительно короткие, то стоило бы изучить исходный код и вручную составить список всех обращений к памяти. Цель в том, чтобы выявить либо промахи кэша, которые происходят на одних и тех же буферах, либо низкую эффективность кэша, либо конфликты кэша, вызванные обращением к памяти нескольких выровненных указателей.
Выявление проблем памяти 121 Потенциальные улучшения Перед тем как приступить к решению проблем памяти, важно убедиться, что память действительно является узким местом. То, что выборка по событиям указывает в приложении места, где имеется много промахов кэша, вовсе не обязательно означает, что там теряется значительная доля производительности. Вследствие внеочередного выполнения команд очень трудно сказать точно, сколько времени уходит на ожидание доступа к памяти, если есть только выборочные данные анализатора VTune. Для того чтобы определить, насколько падает производительность, в дополнение к анализу выборок необходимо проводить эксперименты по производительности. Например, допустим, что выборка времени выполнения указала на горячую точку в функции, которая является также горячей точкой и по количеству промахов кэша уровня 1. Это открытие является очень надежным признаком того, что данное узкое место вызвано обращениями к памяти, но уверенным в этом еще быть нельзя. Проверка и количественная оценка данного узкого места может быть выполнена при помощи экспериментов по производительности. Следующий фрагмент кода добавляет постоянное значение к каждому элементу массива: for (x=0; х<1еп; х++) DestArray[x] = SourceArray[x] + К; Этот цикл распадается на следующие шаги: 1. Загрузить SourceArrау[х]. 2. Добавить К. 3. Сохранить DestArray[x]. 4. Инкрементировать х. 5. Сравнить хи len. 6. Перейти к шагу 1, если х меньше 1 en. Изучение кода делает очевидным, что именно память является узким местом, поскольку этот цикл не делает больше ничего, что могло бы расходовать время. Но насколько серьезно это узкое место? Если учитывать только зависимости по данным, то один проход всех шести шагов может быть выполнен за три такта. Однако хронометраж этого цикла для 256 000 элементов массива показывает, что на один проход расходуется примерно девять тактов. В ходе проведения эксперимента по производительности нужно ликвидировать возможность промахов кэша при чтении и посмотреть, что получится. Код для эксперимента будет выглядеть так: for (x=0; х<1еп; х++) DestArray[x] = SourceArrayEO] + К; Хронометраж этого нового цикла показывает, что время выполнения падает до пяти тактов на элемент. Следующим экспериментом будет ликвидация возможности промахов кэша при записи, как показано здесь: for (x=0; х<1еп; х++) DestArray[0] = SourceArray[x] + К;
122 Глава 8 • Память Теперь производительность составляет примерно четыре такта на элемент. Далее идет вариант вообще без промахов кэша: for (x=0; х<1еп; х++) DestArray[0] = SourceArrayLO] + К; Этот код выполняется примерно за два такта на элемент. После сравнения этого результата с исходными девятью тактами на элемент становится понятно, что доступ к памяти замедляет цикл более чем в четыре раза по сравнению с экспериментом без доступа к памяти. Это ведет к двум выводам. Во-первых, на каждом элементе массива теряется по семь тактов на ожидание промахов кэша. Во-вторых, в данном случае промахи кэша при записи обходятся несколько дороже, чем при чтении. После того как мы закончили этот подробный анализ, настало время начинать решение проблем памяти. Решение проблем памяти Когда известны детали того, где и почему доступ к памяти является узким местом, можно заняться оптимизацией. В следующем далее списке перечислены приемы повышения производительности памяти. □ Решите все выявленные проблемы опережающей записи. Для этого обычно достаточно производить доступ к данным с помощью того же типа данных, который использовался при записи значения. □ Используйте меньше памяти с целью сократить промахи кэша, связанные с принудительной загрузкой. Здесь может помочь выбор другого алгоритма, требующего меньше памяти. Например, некоторые алгоритмы сортировки вставкой работают прямо в исходном массиве, в то время как другим алгоритмам сортировки, наподобие сортировки слиянием, требуется дополнительная временная память. Выбирайте алгоритм, который эффективен как в вычислительном отношении, так и в смысле расходования памяти. Однако помните, эффективные в вычислительном плане алгоритмы могут дать гораздо более серьезный выигрыш, чем потери от нескольких промахов кэша, так что не выбирайте алгоритм исключительно по объему используемой им памяти. Изменение типов данных также может уменьшить объем требуемой памяти. Если целые числа размером 32 бита не нужны, попробуйте использовать слова, байты или даже биты. Эта рекомендация особенно актуальна для приложений на процессорах с архитектурой Intel EM64T, поскольку указатели имеют размер 64 бита. Если вы сможете заменить некоторые указатели 32-разрядными индексами массивов, то сэкономите 32 бита на каждый указатель, и такая экономия часто разделяет приложения на те, что прекрасно укладываются в кэши, и те, что в кэши не укладываются. Выборка значений события 1st Level Cache Misses Retired позволяет увидеть те места, где приложение использует память. Задействуйте эту выборку в качестве путеводителя, который будет направлять вас к тем местам вашего приложения,
Решение проблем памяти 123 где расход меньшего объема памяти даст максимальный эффект. Вам следует также обдумать выполнение выборки по промахам кэша уровня 2, поскольку самое большое увеличение латентности происходит между кэшем уровня 2 и основной памятью. Если промахов кэша уровня 2 совсем мало, то уменьшение объема расходуемой приложением памяти вряд ли существенно скажется на производительности. □ Повышайте эффективность кэша. Для того чтобы убедиться, что используется вся память, изучите, какие строки кэша загружаются. Настройте структуры данных и буферы памяти, чтобы разместить в памяти рядом друг с другом те элементы, которые требуются одновременно. На сегодняшний день не существует инструментов, позволяющих показать эффективность кэша. Однако вы можете составить представление об эффективности кэша, если посмотрите, сколько строк кэша было загружено по отношению к тому объему памяти, который предположительно расходует ваше приложение. □ Читайте память заранее за счет предвыборки. Старайтесь организовать доступ к вашим структурам данных таким образом, чтобы аппаратная предвыборка данных выполнялась естественным образом. Например, если обработка цепочки зависимых указателей занимает много времени, попытайтесь так организовать указатели в списке, чтобы они были естественным образом разнесены, способствуя аппаратной предвыборке требуемых адресов. Если данные нельзя организовать так, чтобы сработала аппаратная предвыборка, то для предварительной доставки данных из памяти в кэш можно задействовать команды программной предвыборки. Когда вы выдаете команду предвыборки достаточно заблаговременно (перед тем, как понадобятся данные), промах кэша тоже возможен, но теперь процессору уже не придется ждать данные, а данные будут ждать процессор в кэше. Всегда используйте тест производительности, проверяя, что команды предвыборки позволили повысить производительность, потому что иногда команды программной предвыборки могут вызвать падение производительности. □ Пишите в память быстрее при помощи команд прямого доступа к памяти. Потоковые команды прямого доступа пишут данные без обращения к кэшу, что экономит одно чтение из кэша (вызванное политикой кэша, которая требует чтения для определения прав владения) и одну запись в кэш. При использовании команд прямого доступа убедитесь, что данные не будут загружаться в ближайшем будущем другой функцией. Запись в память при помощи команд прямого доступа только для того, чтобы опять прочитать из нее обратно в кэш, никак не повысит общую производительность. Команды прямого доступа работают лучше всего тогда, когда записывают данные, которые больше никогда не потребуются процессору (такие как данные для видеобуфера, применяемые только видеокартой). й Избегайте конфликтов. Адрес данных, к которым осуществляется доступ, определяет то место в кэше, куда их можно поместить. Избегайте чтения или
124 Глава 8 • Память записи девяти или более буферов с одинаковым 2-килобайтным выравниванием одновременно, в противном случае кэшу придется вытеснять строку кэша даже в том случае, если кэш не заполнен. Выявлять конфликты 2-килобайтного выравнивания кэша уровня 1 очень сложно, поскольку процессор не имеет счетчика событий для отслеживания этой ситуации. Для обнаружения конфликтов кэша уровня 1 приходится применять сразу несколько подходов: изучение подозрительных мест исходного код изучение в отладчике адресов данных, к которым производится доступ, и про ведение экспериментов по производительности. При подготовке эксперимент по производительности для выявления конфликтов кэша уровня 1 выполните принудительное выравнивание адресов по другой границе или перестаньте обращаться к одному или двум буферам памяти. Если происходили конфликт кэша уровня 1, то вы должны будете заметить изменение количества промахо кэша уровня 1 в анализаторе VTune. 64-килобайтные или 4-мегабайтные конфликты контроллера кэша мо быть обнаружены путем выборки значений счетчика события 64К Aliasing Confli процессора в анализаторе VTune. □ Избегайте проблем, связанных с емкостью кэша. Проблемы емкости кэша вы зываются вытеснением данных до того, как отработаны все ссылки на них. Эт проблемы характерны для двухпроходных алгоритмов, в которых большой буфер обрабатывается одной функцией, после чего следует еще один проход по тому же буферу второй функции. Обе функции вызывают промахи кэша, несмотря даже на то, что им требуются одни и те же данные. Попробуйте работать с более мелкими буферами размером с кэш. Выполните первую функцию над подмножеством набора данных размером с кэш, выполните вторую функцию над этим же набором данных. Если все идет по плану, то во второй функции не будет промахов кэша, поскольку в кэше еще останутся данные, загруженные туда первой функцией. Затем повторно вызовите первую и вторую функции для следующего поднабора данных размером с кэш, и т. д. Будьте внимательны при выборе поднабора данных размером с кэш, поскольку все обращения к памяти, включая переменные стека, глобальные переменные и буфера, вносят свой вклад в проблемы емкости кэша. Из-за всех этих остальных переменных очень редко можно задействовать весь доступный размер кэша уровня 1 (16 или 32 Кбайт). Попробуйте выбрать размер, который легко программировать и который поможет избежать проблем емкости кэша (например, 4 или 8 Кбайт). Используйте счетчик события 1st Level Cache Misses Retired для обнаружения мест промахов кэша. Затем путем изучения исходного кода определите, была ли память уже в кэше. □ Добавьте процессору работы. При ожидании выборки памяти процессор может выполнять не зависящие от нее команды. По возможности используйте эти «бесплатные» такты путем переноса не зависящей от данных работы в те места, где происходят промахи кэша. Промахи кэша будут занимать такое же время,
Решение проблем памяти 125 но теперь процессор сможет выполнять во время ожидания другие команды вместо того, чтобы просто терять время. Пример 8.1. Оптимизация функции Очень важным навыком оптимизации является умение просто по коду (без анализатора производительности) предсказать проблемы производительности и пути их решения. Посмотрите на представленный далее цикл и определите возможные проблемы. Задача Улучшите следующую функцию, предполагая, что массив Dest в ближайшем будущем использоваться не будет, массивы выровнены, а значение 1 en кратно четырем: void AddKtoArray (int Dest []. int Src[]. int len. int K) { int i; for (i=0; i<len; i++) Dest [i] = Src [i] + K; } Решение Прежде всего необходимо отметить, что этот цикл начинает зависеть от памяти тогда, когда массивы не находятся в кэше (он не делает больше ничего, что могло бы сказаться на времени выполнения). Поскольку в постановке задачи говорится, что целевой массив в ближайшем будущем использоваться не будет, необходимо задействовать потоковые команды записи. Лучшим способом улучшения этого цикла является применение команд потоковой записи и SIMD-команд для сложения четырех целых чисел одновременно. Эти изменения повысят производительность примерно на 33 %, но цикл по-прежнему останется весьма зависимым от памяти. Можно произвести дальнейшие улучшения, заставив этот цикл дополнительно работать, чтобы использовать время, которое будет потеряно в ожидании доступа памяти. Можно также сократить объем требуемой памяти, чтобы также уменьшить количество промахов кэша. Еще можно обеспечить нахождение всей требуемой памяти в кэше, используя прием «вскрытия недр» и другую функцию, которая обращается к той же памяти. В следующем фрагменте кода задействованы внутренние команды компилятора и библиотеки классов компилятора C++ производства Intel. // допущение: массивы выровнены по 16-байтным границам // значение len кратно 4 void AddKtoArray4s (int Dest []. int Src[], int len, int K) { int i;
126 Глава 8 • Память _ml28i *Dest4 - (__ml28i *)Dest; Is32vec4 *Src4 = (Is32vec4 *)Src; Is32vec4 K4(K. К. К, К); for (i=0; i<len/4; i++) jnm_stream_sil28(Dest4+i, Src4[i] + K4); } Пример 8.2. Оптимизация структуры данных Способность по одному виду структур данных определить проблемы кэша является важной частью оптимизации программ. Посмотрите на представленную далее структуру данных и укажите потенциальные проблемы производительности и возможные улучшения. Задача Оптимизируйте структуру данных телефонной книжки с целью совершенствования поиска. Структура данных: #define MAX_LAST_NAME_SIZE 16 typedef struct _TAGPH0NE_B00K_ENTRY { char LastName[MAX_LAST_NAME_SIZE]; char FirstName[16]; char email[16]; char phone[10]; char cell[10]; char addrl[16]; char addr2[16]; char city[16]; char state[2]; char zip[5]; _TAGPH0NE_B00K_ENTRY *pNext; } PhoneBook; Функция поиска: PhoneBook * FindName(char Last[], PhoneBook * pHead) { while (pHead != NULL) { if (stricmpCLast. pHead->LastName) == 0) return pHead; pHead - pHead->pNext;
Решение проблем памяти 127 } return NULL; } Решение В первую очередь необходимо понять, что проблема состоит в плохом использовании кэша функцией. Каждая структура имеет размер 127 байт, но только 20 байт задействуются в каждом цикла поиска. При такой организации напрасно теряется 48 из 64 байт строки кэша уровня 1 и 111 из 128 байт строки кэша уровня 2, что для кэша уровня 1 дает эффективность максимум 25 %. Для повышения производительности необходимо переделать структуру таким образом, чтобы все переменные, в которых содержатся фамилии, находились в одном непрерывном массиве, а остальные менее востребованные данные — в другом месте. В результате эти два массива должны быть определены следующим образом: char LastNames[MAX_ENTRIES * MAX_LAST_NAME_SIZE]; PhoneBook PhoneBookHead[MAX_ENTRIES]; Поскольку фамилии могут быть любой длины, то в массиве фамилий может быть потеряно до MAX_LAST_NAME_SIZE - 1 байт, но все равно это существенное улучшение по сравнению с предыдущей версией. Приняв во внимание строки переменной длины, функцию поиска теперь можно написать так: PhoneBook * FindName(char Last[]. char *pNamesHead, PhoneBook *pDataHead, int NumEntries) { int i = 0; while (i < NumEntries) { if (stricmp(Last, pNamesHead) == 0) return (pDataHead+i); i++; pNamesHead += strlen(pNamesHead) + 1: } return NULL; } Узкое место в данном коде смещается на функции сравнения и измерения длины строк, которые необходимо оптимизировать путем создания версий специально для данного приложения. Дополнительную информацию можно найти в главе 10. Дальнейшим улучшением могла бы стать замена алгоритма последовательного поиска алгоритмом двоичного поиска или другим высокопроизводительным алгоритмом.
128 Глава 8 • Память Основные моменты В качестве резюме запомните следующие рекомендации: □ Избегайте подкачки страниц и использования виртуальной памяти. □ Убедитесь в том, что проблемы опережающей записи не вызывают потерь производительности. □ Сосредоточьтесь на анализе мест и причин возникновения проблем кэша уровня 1. □ Оптимизируйте приложения, чтобы могла начать работать аппаратная пред- выборка, или используйте команды программной предвыборки. Оба этих подхода дают памяти возможность раньше попасть в кэш, что позволяет избежать ожидания процессором доступа к памяти. □ Для обнаружения горячих точек памяти можно использовать события 1st Level Cache Misses Retired, Write WC Full, Write WC Partial и 64К Aliasing Conflicts процессора. □ Проводите эксперименты по производительности, чтобы определить серьезность проблем памяти и найти возможные решения.
Циклы Циклы являются основным источником горячих точек исключительно благодаря итерациям: повторяйте что-то достаточно часто, и это что-то станет горячей точкой. Цикл сам по себе не обязательно является узким местом, а по некоторым позициям фактически даже способствует повышению производительности. Во-первых, циклы уменьшают количество хранимых команд. Программам с меньшим количеством команд требуется меньше памяти, поэтому меньше времени проводится в ожидании выборки команд из основной памяти. Во-вторых, процессор Pentium 4 кэширует декодированные команды, так что когда команда выполняется вторично, получается экономия на времени декодирования. В процессоре Pentium M реализована примерно такая же оптимизация, предотвращающая повторную выборку (или декодирование) команд в небольших циклах. Недостатком циклов является то, что они увеличивают издержки. Например, следующий код складывает четыре целых числа. sum = 0; for (1-0! i<4; i++) { sum = sum + array[i]; } Эти же самые четыре числа можно без использования цикла сложить в одной инструкции присваивания: sum = агтау[0] + агтау[1] + агтау[2] + аггау[3]; Версия с циклом выполняет четыре сложения, четыре инкремента и несколько Условных переходов с обычно неверно предсказываемым переходом при выходе из цикла. В отличие от этого версия с единственным присваиванием просто выполняет три сложения. То есть в данном примере версия с циклом работает медленнее, Чем инструкция с единственным присваиванием. Однако если бы в массиве имелось Ю 000 элементов (а не четыре), то версия с циклом была бы быстрее полностью Развернутой инструкции присваивания.
130 Глава 9 • Циклы Следовательно, очень важно знать, когда следует использовать цикл. В табл. 9.1 резюмированы некоторые основные факторы. Таблица 9.1. Достоинства и недостатки циклов Что может сделать циклы быстрыми Меньший объем памяти для команд Экономия времени декодирования, когда команда выполняется повторно Компиляторы при поиске возможностей для оптимизации обычно уделяют циклам повышенное внимание Что может сделать циклы медленными Дополнительные издержки на команды, необходимые для реализации конструкции цикла Выход из цикла по условному переходу обычно предсказывается неверно Конструкции циклов могут налагать дополнительные ограничения на очередность вычислений Современные компиляторы имеют широкий набор вариантов оптимизации, нацеленных именно на циклы, и многие компиляторы могут выполнять все те преобразования циклов, с которыми мы познакомимся в данной главе. Обязательно прочитайте всю документацию компилятора, чтобы понять, какие варианты оптимизации возможны и какие флаги командной строки служат для их включения. Перед тем как начать вручную переписывать цикл, убедитесь, что компилятор не оптимизировал его автоматически. Зависимости по данным играют важную роль в определении тех случаев, когда преобразование цикла допустимо (то есть он сохраняет семантику), поскольку зависимости по данным отражают необходимую очередность выполнения. Поэтому в данной главе сначала представляется концепция зависимости по данным, а затем дается обзор обычных вариантов преобразования циклов. Более подробное изложение этих тем вы можете найти в учебниках [1, 2, 3,4, 39,41]. Зависимости по данным Предположим, что программист написал на языке С следующие две инструкции: sx\ а - 100; s2: b = 200; Последовательная семантика языка требует следующего порядка выполнения инструкций: сначала выполнить инструкцию Sv которая присваивает переменной а значение 100, а затем выполнить инструкцию Sv которая присваивает переменной Ь значение 200. Однако в данном случае выполнение S2 перед Sx или даже одновременное их выполнение не окажет никакого воздействия на окончательные значения переменных. Сравним этот вариант с другой последовательностью инструкций: $,: а - 100; s2: b = а + 200;
Зависимости по данным 131 Теперь во избежание изменения семантики должен быть соблюден последовательный порядок выполнения. Перестановка двух инструкций дала бы для b значение 200 (если начальное значение а равно 0), а не 300 (как это было задумано). В отличие от первого примера, в данном случае между инструкциями S, и S2 имеется зависимость по данным, называемая чтением после записи. Изменению порядка выполнения двух инструкций в общем случае препятствуют зависимости по данным трех видов: р Зависимость по ходу выполнения, обозначаемая как St SfS2, имеет место, если S пишет в переменную, которая затем читается в S2 (чтение после записи). □ Антизависимость, обозначаемая как S{ 8a S2, имеет место, если St читает из переменной, которая затем перезаписывается инструкцией S2 (запись после чтения). □ Выходная зависимость, обозначаемая как St 8° S2, имеет место, если St пишет в переменную, которая затем перезаписывается инструкцией S2 (запись после записи). Эти концепции легко обобщить на инструкции, которые встречаются в циклах, а также на операции чтения и записи массивов (но не скалярных переменных). Во-первых, поскольку инструкции в циклах выполняются несколько раз, такие инструкции фактически порождают несколько экземпляров инструкций. Например, следующий цикл порождает по четыре экземпляра двух инструкций, выполняемые в следующем порядке: ^(0), 52(0), ^(1), 52(1), ^(2), S2(2), 5t(3) и S2(3). for (i =0: i < 4; i++) { ey: a[i] = b[i]; s2: c[i] = c[i+l] + a[i]; } Во-вторых, зависимости по данным возникают только между операциями чтения и записи одного и того же массива, когда фактические значения индексов совпадают. В предыдущем примере зависимость по ходу выполнения Sj(i) 8fS2(i) возникает для каждого значения 0 < i < 4, поскольку эти экземпляры инструкций пишут и читают одни и те же элементы массива а. Таким же образом для каждого значения 0 < i < 3 возникает антизависимость S2(i) 5fl S2(i + 1), поскольку экземпляры S2 читают элемент массива с, который перезаписывается в следующей итерации другим экземпляром той же инструкции. Зависимости по данным между экземплярами инструкций, которые относятся к одной итерации цикла, называются циклически независимыми, а зависимости по данным между экземплярами инструкций, которые принадлежат к разным итерациям Цикла — циклически зависимыми. В примере все зависимости по ходу выполнения массива а являются циклически независимыми, а все антизависимости массива с — циклически зависимыми. Зависимости по данным дают важную информацию о том, когда преобразования цикла допустимы, то есть сохраняют семантику последовательного порядка выполнения (как это разъясняется в следующих разделах).
132 Глава 9 • Циклы Расщепление и слияние циклов При расщеплении цикла, называемом также распределением, управление циклом распределяется между разными инструкциями тела цикла. Вот пример на языке Fortran: DO I = 2, 100 DO I = 2, 100 A(I) = B(I) A(I) = B(I) -> ENDDO C(I) = C(I-l) +1 DO I - 2. 100 ENDDO C(I) = C(I-l) + 1 ENDDO Такое преобразование допустимо, если нет циклических зависимостей по данным, которые лексически направлены назад, то есть идут от одного экземпляра инструкции к другой, которая находится в теле цикла раньше. Преобразование полезно для многих целей, таких как изоляция циклов зависимостей по данным при подготовке к векторизации цикла (см. главы 12 и 13), для выполнения других вариантов преобразования цикла наподобие перестановок циклов или повышения локальности путем уменьшения общего количества данных, на которые делаются ссылки при полном выполнении каждого цикла. Применительно к процессору расщепление цикла можно использовать для разделения отдельных потоков данных в цикле с целью улучшения характеристик аппаратной предвыборки и преодоления проблемы дефицита места в буферах записи. Для иллюстрации последнего эффекта рассмотрим следующую С-функцию, которая сбрасывает несколько сравнительно небольших массивов: #define N 64 int bufi[N]. .... bufn[N]; void filln(void) { int i; for (i = 0; i < N; i++) { bufJ[i] = 0; buf/7[i] = 0; При оптимизации этого кода конкретно под процессор Pentium 4, поддерживающий гиперпоточность (-QxP/-xP), компилятор C++ производства Intel выполняет векторизацию цикла целиком в последовательность 128-разрядных выровненных команд перемещения данных (см. главы 12 и 13). Более того, по умолчанию компилятор C++ производства Intel применяет расщепление и к векторному циклу»
Расщепление и слияние циклов 133 чтобы количество потоков данных в каждом полученном цикле было около четырех (как показано в следующем коде). for (i = 0; i < N; i++) { // векторизирован bufICi] - 0; buf4[i] = 0; } for (i - 0; i < N; i++) { // векторизирован buf5[i] = 0; buffi[i] = 0; На рис. 9.1 показано время выполнения в тактах системных часов для векторизованного цикла и различного количества массивов при выключенном и включенном режиме расщепления цикла. Как и предполагалось, время выполнения обеих версий растет при увеличении количества массивов в цикле (просто потому, что цикл должен выполнять больше работы). Однако без расщепления цикла имеет место потеря производительности тогда, когда цикл исчерпывает количество потоков данных, которые может эффективно обработать процессор. Поскольку компилятор C++ информирован о таких ограничениях ресурсов, при компиляции по умолчанию выполняется расщепление, чтобы получить несколько более быстрых циклов. векторизация без распределения векторизация с распределением 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 Количество массивов Рис. 9.1. Количество тактов системных часов для сохранения небольших массивов
134 Глава 9 • Циклы Обратное расщеплению циклов преобразование, при котором соседние циклы с одинаковым управлением объединяются в один цикл, называется слиянием циклов. Вот пример на языке Fortran: DO I = 1. N A(I) =0 DO I = 1, N ENDDO Ad) - 0 DO I = 1. N -> B(I) = 0 B(I) = 0 ENDDO ENDDO Это преобразование допустимо, если слияние не вносит никаких лексически направленных назад зависимостей по данным и может быть использовано, например, для снижения издержек цикла, для детализации работы, выполняемой в цикле перед тем, как реализовать в нем параллелизм (см. главу 15), для увеличения локальности путем объединения циклов, которые ссылаются на одни и те ж массивы. Чистка циклов При чистке циклов одна или более итераций переносится в отдельный код вн цикла. Простой пример показан в следующем фрагменте С-кода: а[0] = к; к = 0; for (i=0;i<16;i++) { -> for (i=l;i<16;i++) { a[i] = к; a[i] = к; к - i; к - i; } } Такое преобразование допустимо всегда при условии, что дополнительны итераций не добавляется. Когда количество проходов цикла не является констан той, очищенный код может понадобиться защитить дополнительными тестам (времени выполнения) на достаточность количества итераций. В предыдуще" примере чистка цикла делает возможным округление переменной к, посколы в очищенном цикле правостороннее вхождение этой переменной можно замени выражением i - 1. Чистка цикла может использоваться также для принудительной реализаци определенного начального выравнивания в памяти ссылок на массивы перед век торизацией цикла. Специальным случаем этого преобразования цикла являете динамическая чистка цикла, когда количество итераций, которые необходим чистить, вычисляется на стадии выполнения, а чистка реализуется при помощ отдельного цикла перед фактически векторизуемым циклом. Компиляторы С+ и Fortran производства Intel используют как статическую, так и динамическу чистку циклов, чтобы обеспечить лучшее выравнивание памяти для SIMD-ko (детали см. в главах 12 и 13).
Развертывание циклов и свертывание развернутых циклов 135 Развертывание циклов и свертывание развернутых циклов Еще одно часто встречающееся преобразование цикла — развертывание. При развертывании цикла происходит объединение двух или более итераций цикла и соответствующее уменьшение количества проходов, как показано в следующем фрагменте кода: // исходная версия sum - 0; for (i=0; i<1000; i++) { sum += array[i]: } // развернутая версия (коэффициент 4) sum = 0; for (i»0; i<1000; i+=4) { sum += array[i]; sum += array[i+l]; sum += array[i+2]; sum += array[i+3]: } В развернутой версии цикла больше кода, зато выполняется меньше команд, являющихся источником издержек. В обеих версиях происходит одна и та же тысяча сложений, но индекс цикла инкрементируется в оригинальной версии 1000 раз, а в развернутой — только 250. Производительность этого цикла зависит от состояния кэша трасс и кэша уровня 1, но вообще развернутая версия работает быстрее, поскольку выполняет меньше команд, являющихся источником издержек. Однако в некоторой точке выигрыш в производительности от сокращения количества команд теряется из-за добавочных расходов на выборку и декодирование большего количества команд. Например, не следует ожидать большого выигрыша от полного развертывания этого цикла в тысячу инструкций. Для дальнейшего улучшения этого кода можно было бы также попробовать Уменьшить зависимости по данным (как показано в следующем фрагменте): tl - t2 = t3 = t4 = 0; for (i=0; K1000: i+=4) { tl += array[i]; t2 += array[i+l]; t3 += array[i+2];
136 Глава 9 • Циклы t4 += array[i+3]; } sum = tl + t2 + t3 + t4; Эта версия ослабляет наложенные на очередность выполнения сложений ограничения, что позволяет повысить параллелизм на уровне команд при выполнении цикла. Рост производительности, полученный сокращением зависимостей по данным, превышает снижение производительности из-за трех сложений в конце. Сокращение зависимостей по данным обычно весьма желательно (как это уже обсуждалось в главе 6). Нет универсального правила, которое могло бы указать, когда и в какой степени необходимо развертывать цикл. Еще больше все осложняет то, что большинство современных оптимизирующих компиляторов умеет развертывать циклы автоматически. Некоторые усовершенствованные компиляторы даже применяют обратное преобразование, называемое свертыванием цикла и позволяющее отменить результаты ручной оптимизации цикла развертыванием (с целью повышения точности анализа программы или выбора других коэффициентов развертывания, лучше соответствующих целевому процессору). Однако определенные рекомендации существуют, и можно без труда изучить результаты работы компилятора, чтобы увидеть, какие варианты оптимизации были применены автоматически. Самое главное — подумайте, почему цикл медленный, и займитесь этой проблемой. Вероятно, не стоит выполнять развертывание цикла, содержащего много дорогостоящих операций, а также операций зависимых по данным. Полезно такое развертывание цикла, которое ведет к меньшему количеству зависимостей по данным или к более удачной смеси команд. Так же как и с любыми вариантами оптимизации, обязательно проведите тест производительности, чтобы удостовериться в повышении производительности. Используйте следующие рекомендации, принимая решение о полезности развертывания и максимизации его потенциала. □ Небольшое количество проходов цикла, небольшое тело цикла. Замените циклы с небольшим количеством проходов и небольшими телами версией без циклов. Эта оптимизация очень похожа на вариант из примера суммирования 4-элементного массива при помощи трех сложений в одной инструкции вместо цикла. Ликвидация цикла, скорее всего, даст более быструю версию, но будьте осторожны и не меняйте того, что работает. Циклы такого типа вряд ли являются горячими точками (если только цикл не входит в другую часто выполняющуюся конструкцию, либо в теле цикла не выполняются очень дорогостоящие операции наподобие тригонометрических). Не нужно переписывать цикл, если реальная проблема кроется в другом месте. □ Небольшое количество проходов цикла, большое тело цикла. Развертывайте циклы с небольшим количеством проходов и большими телами, чтобы сократить зависимости по данным. Если тело цикла большое, процессор может не суметь обеспечить максимально возможный параллелизм. Вы должны развернуть цикл и чередовать итерации, чтобы можно было одновременно выполнять несколько команд (давая процессору хорошую смесь команд и сокращая зависимости по данным).
Развертывание циклов и свертывание развернутых циклов 137 р Большое количество проходов цикла, небольшое тело цикла. Обдумайте возможность развертывания циклов с большим количеством проходов и небольшими телами. Компилятор обычно хорошо справляется с оптимизацией таких циклов, но развертывание вручную иногда может дать дополнительный выигрыш в производительности. При развертывании подобных циклов попробуйте устранить зависимости по данным и используйте хорошую смесь команд. Неразвернутый цикл, полный команд деления с плавающей точкой или обращений к памяти, нельзя назвать хорошей смесью команд. Такие циклы обычно являются главными кандидатами на реализацию с помощью SIMD-команд. Попробуйте использовать компиляторы C++ и Fortran производства Intel, чтобы провести векторизацию этих циклов автоматически, либо задействуйте библиотеки классов C++, встроенные команды компилятора или встроенный ассемблер (детали см. в главах 12 и 13). □ Большое количество проходов цикла, большое тело цикла. Развертывайте циклы с большим количеством проходов и большими телами только для сокращения зависимостей по данным и получения лучшей смеси команд. Дополнительный объем исходного кода, требования к объему кэша трасс и кэша команд, время декодирования — все эти факторы при развертывании циклов с большим количеством команд обычно играют против вас (к тому же в любом случае издержки на саму организацию цикла при большом теле цикла не столь существенны). Пример 9.1. Оптимизировать путем развертывания цикла Иногда развертывание цикла с условием, которое зависит от индекса цикла, может сделать оптимизированный код гораздо проще и быстрее исходного. Попробуйте оптимизировать следующий цикл. Задача Увеличьте производительность следующего кода путем развертывания цикла с целью ликвидации ветвления и сокращения количества команд, приводящих к издержкам: for (1-0; i<1000; i++) { if (i & 0x01) do_odd(i); else do_even(i); } Решение He просмотрите очевидных причин для развертывания цикла, в данном случае в Развертывании есть смысл. Решением является однократное развертывание цикла с Целью удаления ветвления из тела цикла.
138 Глава 9 • Циклы for (i-0; i<1000; i+=2) { do_even(i); do_odd(i+l); } Перестановка циклов Другим важным преобразованием циклов является перестановка. При этом преобразовании меняются местами циклы, вложенные один в другой. Вот пример на языке Fortran: DO I = 1. М DO J - 1. N DO J = 1. N DO I - 1. M Ad,J) = 0.0 -> A(I.J) = 0.0 ENDDO ENDDO ENDDO ENDDO Преобразование допустимо, если самый внешний цикл не несет в себе никаких зависимостей по данным, идущих от одного экземпляра инструкции, выполняемого для I = / и J =у, к другому экземпляру инструкции, выполняемому для I = /' и J =/ (где i < i vij >/). Перестановки циклов могут быть полезны по многим причинам. Например, поскольку Fortran хранит массивы по столбцам, то данное преобразование изменяет обращения к памяти таким образом, что последовательные итерации внутреннего цикла обращаются к элементам, которые в памяти расположены рядом друг с другом. Для языка С, который хранит массивы по строкам, такой же эффект можно было бы получить за счет обратной перестановки циклов. Вообще перестановку циклов можно использовать для изменения локальности в цикле, перемещения определенных циклов внутрь или наружу, изменения структуры зависимостей по данным и преобразования зависимых от итераций цикла вычислений в независимые. Часто для достижения определенной цели перестановку циклов приходится объединять с другими преобразованиями циклов. Рассмотрим, например, следующий цикл: /* исходный код */ for (i=0; i<4; i++) { a[i] = 0; for (j-0; j<4; j++) { a[i] +- b[j][i]; } }
Перестановка циклов 139 Для того чтобы лучше подготовить этот цикл к векторизации, желательно обеспечить доступ к массиву b с шагом в один элемент. Для этого сначала проводится расщепление внешнего цикла, в ходе которого инициализация массива а помещается в собственный цикл и создается вложенный цикл вокруг второй инструкции: /* код после расщепления цикла */ for (1=0; i<4; i++) { a[i] = 0; } for (i=0; i<4; i++) { for (j=0; j<4; j++) { a[i] += b[j][i]; } } Затем выполняется перестановка циклов, что дает обращения к массиву b с шагом в один элемент: /* код после расщепления и перестановки */ for (i=0; i<4; i++) { a[i] - 0; } for (j=0; j<4; j++) { for (i=0; i<4; i++) { a[i] *m Mj][i]; } } Полученный фрагмент гораздо лучше подходит для SIMD-команд. Например, для массивов а и Ь, состоящих из чисел с плавающей точкой одинарной точности, компилятор C++ производства Intel автоматически выполняет векторизацию этого фрагмента в следующие компактные SIMD-команды (детали этого преобразования см. в главах 12 и 13): рхог хог addps add cmp jb movaps xmmO, xmmO eax, eax xmmO, XMMWORD PTR _b[eax] eax, 16 eax, 64 L XMMWORD PTR _a, xmmO Для более старых версий компиляторов Intel эти варианты преобразования циклов приходилось выполнять вручную. Однако по мере совершенствования
140 Глава 9 • Циклы компиляторов все больше и больше таких преобразований будет выполняться автоматически. Компиляторы Intel могут также объединять перестановки циклов с приемом вскрытия недр, когда пространство итерации цикла разбивается на блоки. Такое объединение преобразований цикла, называемое разделением на блоки, или расположением мозаикой, обеспечивает очень важную в отношении повышения производительности кэша оптимизацию за счет улучшения как пространственной, так и временной локальности во вложенном цикле. Подробное изложение данной темы можно найти в литературе [1]. Вычисления инвариантов цикла Вычисления, которые не меняются между итерациями цикла, называются вычислениями инвариантов цикла. Большинство оптимизирующих компиляторов для повышения производительности переносит такие вычисления наружу цикла. Однако компиляторы, к сожалению, не могут обнаружить и удалить все вычисления инвариантов, поэтому может потребоваться помощь программиста. Например, вычисление выражения val /3 в следующем примере внутри цикла не меняется, поэтому его, вроде бы, можно вынести из цикла. Но можно ли? // все переменные - целые for (XHD; x<end; x++) { аггау[х] = х * val/3; } Вынос вроде бы инвариантного вычисления val/З из цикла и умножение (вместо него) на временную переменную внутри цикла может изменить результат, поскольку выражение следует вычислять как (х * val )/3, что необязательно идентично х * (val/З) в случае целочисленных вычислений. Такая оптимизация является безопасной только тогда, когда переменная val кратна трем и умножение не переполняет целое число. В данном случае компилятор не стал бы выносить деление из цикла, но программист мог бы сделать это, если бы знал, что эти условия соблюдаются. Вызовы функций также могут быть инвариантами. В следующем цикле компилятор не знает, является ли вызов f 00 () инвариантом, поэтому он вызывает данную функцию каждый раз при прохождении цикла. Если программист знает, что вызов этой функции является инвариантом и ее можно вызвать только один раз, то этот вызов можно вынести из цикла. При междупроцедурных вариантах оптимизации (флаги -Qipo/-ipo) компилятора Intel некоторые вызовы функций могут быть распознаны как инвариантные относительно цикла и вынесены из цикла автоматически: for (x=0; х<100; х++) { аггау[х] = х * foo(val); }
Инвариантные относительно цикла переходы 141 Инвариантные относительно цикла переходы Удаление из циклов ветвлений весьма важно, поскольку такие переходы осложняют выполнение оптимизации компиляторами. Инвариантные переходы иногда могут быть вынесены из цикла, как показано в следующем фрагменте кода: // исходная версия void BlendBitmapCBYTE Dest[]. BYTE Srcl[], BYTE Src2[], int size, BYTE blend) { int i; for (1-0; i<size; 1++) { if (blend == 255) Dest[i] = Srcl[i]; else if (blend == 0) Dest[i] = Src2[i]; else Dest[i] = (Srcl[i] * blend + Src2[i] * (255-blend)) / 256; } } // улучшенная версия - без переходов внутри циклов void BlendBitmapOpt(BYTE Dest[]. BYTE Srcl[]. BYTE Src2[]. int size. BYTE blend) { int i; if (blend == 255) for (1*0; i<size; 1++) Dest[i] = Srcl[i]; else if (blend == 0) for (1-0; i<size; i++) Dest[i] = Src2[i]; else for (i=0; i<size; )++) Dest[i] = (Srcl[i] * blend + Src2[i] * (255-blend)) / 256; }
142 Глава 9 • Циклы В оптимизированной версии соответствующие условия оцениваются только один раз, после чего выполняется цикл без последующих условных переходов. Инвариантные относительно цикла результаты Иногда циклы служат для инициализации массивов значениями, которые в приложении могут только читаться, но не меняться. Например, следующий код вычисляет факториалы от 0 до 11 и сохраняет результаты в массиве: int Factor! alArray [12]; Factorial Array [0] = 1; for 0-1; i<12; i++) { Factorial Array [i] = FactorialArray [i-1] * i; } Поскольку этот цикл наполнен зависимостями по данным, выжать из него что-либо в смысле производительности нельзя. Если вы обнаружите, что циклы такого типа являются горячими точками, то самым простым способом повысить производительность будет вычислить результаты при компиляции и просто сохранить значения: int FactorialArray[12] = { 1. 1. 2, 6. 24, 120, 720, 5040, 40320. 362880, 3628800, 39916800 }: Основные моменты Запомните следующие общие правила: Q Вследствие своей повторяемости циклы обычно являются источником проблем производительности. □ Важными вариантами преобразования циклов являются расщепление и слияние циклов, очистка циклов, развертывание и последующее свертывание развернутых циклов, перестановка циклов, удаление из циклов инвариантных вычислений, переходов или результатов. □ Поскольку оптимизирующие компиляторы выполняют преобразование циклов автоматически, перед тем как вручную переписывать цикл, убедитесь, что компилятор еще не оптимизировал этот цикл автоматически.
Медленные операции Иногда горячая точка оказывается просто в медленном фрагменте кода. Это может быть системный вызов, дорогостоящая последовательность вычислений или просто дорогостоящая команда наподобие FCOS, SQRTPD или IDIV — что бы это ни было, оно работает медленно. Принято думать, что медленные операции сами по себе такие медленные, и для повышения производительности сделать ничего нельзя. Но не сдавайтесь слишком рано. Обычно можно существенно повысить производительность, если найти возможность вообще отказаться от медленной операции или модифицировать операцию, сохранив только ее «хорошие» части. В большинстве случаев операции являются медленными потому, что созданы для решения обобщенных задач. Например, строковые функции, наподобие scanf, работают со всеми типами входных данных. Даже если требуется только часть ее функциональных возможностей (например, преобразование шестнадцатеричных значений), функция scanf все равно будет задействовать все свою способности. В данном случае (и во многих других) специализированная функция могла бы легко превзойти по производительности обобщенную. Если знаешь размер и выравнивание данных, а также состояние кэша, специализированную функцию обычно писать проще, и производительность ее выше. Медленные команды Команды являются медленными по одной (или нескольким) из перечисленных далее причин: □ Большая латентность. Латентность — это время в тактах процессора от момента, когда начинается выполнение команды, до момента его завершения. Для команд, таких как сложение и вычитание, латентность составляет один такт. Но для команд, наподобие деления с плавающей точкой, латентность может достигать 23 и более тактов (в зависимости от точности). Команды с большой
144 Глава 10 • Медленные операции латентностью снижают производительность только тогда, когда от их результата зависят другие команды. Выполнение команды с большой латентностью тогда, когда к выполнению готовы другие, не зависящие от нее команды, обычно не сказывается на производительности. Вот некоторые из часто используемых команд с большой латентностью: обращения к памяти при промахе кэша, деление, умножение, извлечение квадратного корня, вычисление логарифма и вызов тригонометрических функций. □ Низкая пропускная способность. Пропускная способность определяет количество одновременно выполняемых команд одного типа. Она выражается в виде минимального количества тактов между запусками двух команд одного типа. Например, умножение чисел с плавающей точкой имеет пропускную способность в два такта, поэтому каждые два такта можно начинать новое умножение (несмотря на то, что для получения каждого ответа требуется больше чем два такта). А деление с плавающей точкой одинарной точности имеет пропускную способность и латентность 23 такта, то есть единовременно может выполняться только одно деление. □ Не готовы аргументы. Самой частой причиной медленного выполнения команд являются, вероятно, зависимости по данным. Когда аргументы команд не готовы из-за зависимости от предыдущих вычислений или выборки из памяти, команды ждут внутри процессора, создавая впечатление длительного времени выполнения. □ Отсутствие доступных портов выполнения. Процессоры Pentium 4 и Pentium M могут одновременно выполнять много команд, но есть и ограничения. Например, извлечение квадратного корня числа с плавающей точкой и деление не могут выполняться одновременно, поскольку им нужен один и тот же порт выполнения. В то же время одновременно могут выполняться несколько команд арифметико-логического устройства (АЛУ), таких как сложение, вычитание и сравнение, поскольку процессор имеет несколько портов выполнения, которые могут обрабатывать команды АЛУ. С целью достижения максимальной производительности процессор изначально спроектирован для обработки смеси команд. □ Сериализация. Команды сериализации являются могильщиками производительности, поскольку они останавливают внеочередной ход выполнения команд. Простым примером является команда CPU ID, которая обычно используется для определения типа процессора. Решение проблем медленных команд в первую очередь состоит в нахождении другой работы на время задержки или отыскании возможности отказаться от использования медленных команд. Например, если в алгоритме требуется деление, то было бы неплохо найти 23 такта, не имеющих зависимостей по данным команд для одновременного их выполнения, либо найти способ избежать деления за счет вычитаний, сдвигов или применения справочных таблиц. Такой пример был показан в главе 6 в гибридной версии алгоритма Эвклида, предназначенного для
Справочные таблицы 145 нахождения наибольшего общего делителя. Иногда слияние двух функций или развертывание цикла может помочь найти дополнительную работу и открыть новые возможности для оптимизации. Справочные таблицы Часто применяемым подходом с целью избежать выполнения медленных команд является использование справочной таблицы для хранения предварительно просчитанных результатов. Это может помочь, поскольку скорость работы памяти выше, чем скорость вычислений. Однако так как возможности процессоров растут, а быстродействие памяти остается прежним, эффективность справочных таблиц может упасть. При применении справочных таблиц следует помнить три момента: □ Организуйте таблицу так, чтобы максимизировать попадания кэша. Доступ к кэшу очень быстрый, однако обращения к памяти из-за промахов кэша выполняются медленно. При разработке таблицы подумайте, какие ее элементы могут быть использованы совместно и как лучше всего организовать таблицу, чтобы добиться максимального количества попаданий кэша. Иногда для повышения производительности можно использовать не очень очевидную индексную функцию или небольшое сжатие. □ Поддерживайте таблицу небольшого размера. Таблица меньшего размера оставляет в кэше больше места для других вещей. Даже если кэш достаточно велик для хранения всей таблицы, все равно следует стараться использовать минимально возможную таблицу, поскольку вам требуется повышать производительность всего приложения, а не только справочной таблицы. Берегите кэш для более важных операций. □ Сохраняйте как можно больше результатов вычислений. Всегда старайтесь создавать таблицу с максимально возможным количеством предварительно подсчитанных значений, чтобы избавиться от как можно большего количества медленных команд. Однако будьте осторожны, большее количество значений может иногда привести к большему расходу памяти, что способно снизить производительность. Необходимо соблюдать баланс между использованием дополнительной памяти и выполнением большего количества вычислений. Пример 10.1. Оптимизация за счет использования справочных таблиц Для кардинального повышения производительности можно задействовать одновременно несколько справочных таблиц, сводя при этом потребность в дополнительной памяти к минимуму. Представленный пример иллюстрирует этот подход.
146 Глава 10 • Медленные операции Задача Следующая функция конвертирует 32-разрядное растровое изображение в черно- белое. Повысьте производительность этой функции путем использования одной или более справочных таблиц. void RGBtoBW(DWORD *pBitmap, DWORD width, DWORD height, long stride) { DWORD row, col; DWORD pixel, red. green, blue, alpha, bw; for (row=0; row<height; row++) { for (col=0; col<width; col++) { pixel = pBitmap[col + row*stride/4]; alpha = (pixel » 24) & Oxff; red = (pixel » 16) & Oxff; green - (pixel » 8) & Oxff; blue = pixel & Oxff; bw = (DW0RD)(red * 0.299 + green * 0.587 + blue * 0.114); pBitmapCcol + row*stride/4] = (alpha«24) + (bw«16) + (bw«8) + (bw); } } } Решение Прежде всего, необходимо отметить, что черно-белый пиксел получается масштабированием трех цветовых компонентов цветного пиксела и добавлением значения альфа-канала. Таблица с самым большим количеством вычислений: BlackWhitePixel = BigTable[RGBpixel]; К сожалению, эта таблица огромна. Поскольку пиксел может принимать 232 различных значения, и каждое значение имеет размер 32 бита, то размер таблицы составляет 232 х 4 байта =16 Гбайт, а это явно слишком много. Большую часть времени занимает выполнение трех умножений с плавающей точкой, за которыми следует преобразование к целому числу. Таблица с результатами этих вычислений могла бы использоваться со следующим кодом:
Справочные таблицы 147 BlackWhitePixel = BigTable[RGBpixel & OxOOffffff] + RGBpixel & Oxff00000000; Эта таблица имела бы размер 224 байт =16 Мбайт, что все равно слишком много. Хорошим компромиссом могло бы стать применение трех 256-байтных таблиц — по одной на каждое умножение. Тогда соответствующая строка могла бы выглядеть так: bw = (DW0RD)mul299[red] + (DW0RD)mul587[green] + (DW0RD)mull44[blue]; pBitmapEcol + row*stride/4] = (alpha«24) + (bw«16) + (bw«8) + bw; Использование этих трех справочных таблиц повышает производительность примерно на 400 %, но возможны и дополнительные варианты оптимизации. Применение четвертой таблицы поможет избежать операций сдвига и сложения в последней инструкции. Строка изменится таким образом: pBitmapEcol + row*stride/4] = (alpha«24) + BWMerge[bw]; Появление четвертой таблицы для слияния повышает производительность еще на 50 % (при этом требуется только 256 х 4 = 1 Кбайт дополнительной памяти). При помощи простой маски можно также избежать двух сдвигов для альфа- канала. Таким образом, использование трех 256-байтных таблиц и одной 1024-байтной таблицы (всего 1772 байта), а также ликвидация двух операций сдвига повышают производительность примерно на 700 %. Новая функция с таблицами выглядит так: void tblRGBtoBW(DW0RD*pBitmap,DWORD width, DWORD height.long stride) { DWORD row, col; DWORD pixel, red, green, blue, alpha, bw; for (row=0; row<height; row++) { for (col-0: col<width; col++) { pixel - pBitmapEcol + row*stride/4]; alpha - pixel & Oxff000000; red - (pixel»16) & Oxff; green = (pixel»8) & Oxff; blue - pixel & Oxff; bw = (DW0RD)mul299[red] +
148 Глава 10 • Медленные операции (DW0RD)mul587[green] + (DW0RD)mull44[blue]; pBitmapCcol + row*stride/4] = alpha + BWMerge[bw]; } } } А вот код создания таблиц: BYTE mul299[256]; BYTE mul587[256]: BYTE mull44[256]; DWORD BWMerge[256]; for (i=0; i<256; i++) { mul299[i] = (BYTE)C(float)i * 0.299f); mul587[i] = (BYTE)((float)i * 0.587f); mull44[i] - (BYTE)((float)i * 0.144f); BWMerge[i] = (i«16) + (i«8) + i; } В ходе дальнейшей доработки можно уменьшить размер таблиц. Красная таблица меняет значения примерно через каждые три элемента, а синяя — только примерно через семь. Используя удобные степени двойки, красная таблица могла бы иметь размер вдвое меньше, а синяя — в четыре раза меньше. Потребность в дополнительной памяти сократится на 192 байта — вряд ли достойное для экономии значение (если только место в кэше не является дефицитом). Системные вызовы Иногда горячие точки обнаруживаются не в самих приложениях, а в других местах, таких как операционная система, внешние библиотеки или драйверы устройств. Когда вы с этим столкнетесь, не думайте, что оптимизация закончена. Просто для повышения производительности вам придется использовать другую стратегию. В таких ситуациях помогают четыре вещи: □ Делайте меньше вызовов. Многие функции эффективнее всего работают с большими наборами данных (так как издержки относительно меньше). Например, библиотеки трехмерной графики работают быстрее всего с большими буферами пиков, что помогает минимизировать издержки вызовов и зависимости по данным при вычислениях. Пользуясь интуицией и экспериментами по производительности, можно определить самый эффективный
Системные вызовы 149 путь объединения нескольких вызовов функций в один вызов, который будет обрабатывать больше данных за один раз. Другой распространенный вызов функции с большими издержками — выделение памяти малыми объемами. Будет гораздо лучше выделить большой блок памяти и распределить его самостоятельно, чем делать много вызовов для выделения небольших блоков памяти. Не забудьте изучить документацию и поискать в Интернете информацию, способную вам помочь. Когда вы поймете, почему вызов функции медленный, вы сможете переписать ваше приложение с целью более эффективного ее вызова. □ Вызывайте одну и ту же функцию по-разному. Некоторые функции дают огромную разницу в производительности в зависимости от своих аргументов. Серьезно повлиять на производительность могут такие вещи, как выравнивание памяти и длина буферов. Например, функция копирования памяти работает значительно лучше на выровненных буферах. □ Вызывайте другую функцию. Некоторые функции просто сами по себе медленные, и самое лучшее, что можно сделать — это потратить время на поиск замены. Иногда можно найти для замены аналогичные функции с более высокой производительностью или оптимизированную версию из той же самой библиотеки. Хорошим местом, где можно найти оптимизированные функции, являются библиотеки Intel® Performance, которые представляют собой коллекцию оптимизированных библиотек для операций с матрицами, цифровой обработки сигналов, обработки речи и изображений. Их можно найти на сайте Intel Performance Libraries по ссылке с домашней страницы сайта Intel Software Development Products. Библиотеки Intel Performance содержат базовые процедуры линейной алгебры (Basic Linear Algebra Subprograms, BLAS), которые используются для выполнения основных векторных и матричных операций. Дополнительную информацию о BLAS можно найти в Интернете и во многих книгах. Компилятор C++ производства Intel также содержит несколько внутренних команд времени выполнения, таких как memcpy, memset и strcpy, которые дают очень высокую производительность и автоматически используются вместо функций стандартной С-библиотеки времени выполнения (при сборке компилятором Intel). □ Пишите функции сами. Когда ничто другое не помогает, наступает время написать собственную версию функции. Большинство функций, особенно от сторонних производителей, предназначено для общих случаев. Поскольку вы обладаете конкретными знаниями о вашем приложении и его алгоритмах, структурах данных и состоянии кэша, у вас есть хорошие шансы на то, чтобы превзойти даже лучшие функции общего назначения. Помните следующее: при написании алгоритма вы должны искать то, что специфично для вашей реализации, и использовать это для достижения максимальной производительности.
150 Глава 10 • Медленные операции Пример 10.2. Совершенствование функции Отыщите способ использовать знание конкретного алгоритма для повышения производительности. Задача Следующая функция принимает массив шестнадцатеричных символов и созда второй массив эквивалентных целых чисел. Например, если в текстовом буфер было четыре символа «АЕ92», то в шестнадцатеричном буфере будет два байта бе знака ОхАЕ (174d) и 0x92 (146d). void TxtToHex (BYTE * pHex, char Txt[]. int length) { int i. x; for (i=0; i<length; i++) { sscanf (Txt+2*i, «*02x», &x); pHex[i] « (BYTE)x; } } Решение Проблема состоит в том, что С-функция sscanf времени выполнения является подпрограммой общего назначения, которая для данного случая чрезвычайно неэффективна. Лучшим решением будет написать 2-символьную функцию пре образования текста в шестнадцатеричное число, информированную о том, что необходимо преобразовывать ровно два символа, причем без пробелов. Новая улучшенная функция: void TxtToHexFast (BYTE * pHex, char Txt[], int length) { int i. a, b; for (i=0; i<length; i++) { a = (int)Txt[i*2J; b = (int)Txt[i*2+l]; if (a >= 'A') a = a - 'A'; else a = a - '0'; if (b >= 'A') b = b - 'A'; else
Простой системы 151 b = b - 'О'; pHexCi] = (BYTE)((a«4) + b); } } Эта новая версия примерно в 1000 раз быстрее, чем sscanf. К сожалению, здесь имеют место неверные предсказания переходов, поскольку инструкции i f работают со случайными данными. Быстрее будут работать справочные таблицы, как показано в следующем коде: BYTE LookupA[23] « { 0x00, 0x10, 0x20, 0x30, 0x40, 0x50. 0x60, 0x70, 0x80, 0x90, Oxff, Oxff, Oxff, Oxff, Oxff, Oxff, Oxff, // skip : ; < = > ? @ OxaO, OxbO. OxcO, OxdO, OxeO, OxfO}; BYTE LookupB[23] = { 0x00, 0x01, 0x02, 0x03. 0x04, 0x05, 0x06, 0x07, 0x08, 0x09. Oxff. Oxff. Oxff. Oxff. Oxff. Oxff, Oxff, // skip : ; < = > ? @ 0x0a. 0x0b, OxOc OxOd, OxOe, 0x0f}; void TxtToHexTable (BYTE * pHex, char Txt[], int length) { i nt i. a. b; for (i=0; i<length; i++) { a - (int)Txt[i*2-'0']; b = (int)Txt[i*2+l-'0']; pHex[i] = LookupA[a] + LookupB[b]; } } Эта новая функция использует две таблицы общим размером 46 байт и выполняется еще в семь раз быстрее, что приводит к суммарному ускорению почти в 8000 раз по сравнению с применением функции sscanf. Простой системы Цикл простоя системы — это король всех медленных операций. Операционная система автоматически выполняет запускает процесс простоя системы, когда нет
152 Глава 10 • Медленные операции других готовых к выполнению процессов. Когда выполняется процесс простоя системы, это всегда является признаком того, что процессор теряет время, ожидая, пока что-нибудь произойдет. Медленные устройства ввода-вывода (такие как жесткие диски и генераторы событий синхронизации) обычно вызывают переход приложения в режим ожидания, что позволяет выполняться процессу простоя системы. Целью оптимизации производительности является отсутствие простоя системы. Время простоя системы можно получить при помощи счетчика % Idle Time монитора производительности для объекта Processor (как показано на рис. 10.1). Когда время простоя системы равняется 100 %, то система полностью простаивает. На рисунке система простаивает от 0 до 70 % времени. File Action View Favorites Window Help ■+ I EOS fj? -IfflM |C~1 Console Root gfj System Monitor @ Performance Logs and Alerts D Q :f@ QUI +X9 ЧавШ'0»$ 0.000 Average Maximum 9.401 Minimum 74.750 Duration 0.000 1:*0 Color Scale Counter ! Instance Parent 1 Object Рис. 10.1. Монитор производительности показывает время простоя системы Анализатор VTune также можно использовать для получения времени простоя системы. На рис. 10.2 показан скриншот анализатора VTune, изображающего процесс простоя системы (счетчик System Idle Process). Когда процессор не занят на 100 %, усилия по оптимизации должны быть направлены на то, чтобы определить причину ожидания системы. Иногда причина очевидна, например, текстовый процессор ждет ввода пользователя, но в других случаях необходима определенная работа в качестве детектива. К сожалению, углубление в диаграмму анализатора VTune покажет только ассемблерный код
Простой системы 153 6^? File £ckt Yrew Activity Configure Window rjelp (b iff Ъ & - ' ! **. ta U : ^ ^ "^ : 'JActrntyl (Sampling) ~3 > м a x i «a»' 3^-9- Ш\\ат J3 3 ^ос»»* 5? Thread Ф "o*** *^ '" Process i svchostexe naPrdMgrexe mcsh»ld.exe vtuneccaexe svchostexe wuaudtexe svchostexe ► System Idle Process Everts Instructions Retired samples(1 Б) Clocktickssamples(16) Instructions Retied events(16) Instructions Retired 2(16) Clocktrcksevents(1G) OockticksX(16) Qockticks per Instructions Retired (C... Total 40.1 68...' 205.77... 156.Б1... 17.60 38.186.... 84.52 243.82 lOO 10.00 20.00 30.00 40.00 50.00 6000 70.00 8000 90.00 100' У J ±l Event ActivitylD Scale Sample Alter Value TotalSamples Durabon(s) RingO Ring3 StartTime fractions Retired 16 '"* 0 0000001 OOOOx 3899 228261 99 996 157354 УозЬГ 9/2S720061.33:56 PV Oocktcks 16 OOOOOOOOOIOOx 185577 243446 99 996 232663 10783 9/29/20051:33:56 Pfv t Clockticks per Instructions Retired (CPI) 16 01OOOOOOOOOOx - - - - 1 ±1 Sampling Modules - (Sampling Results) Sampling Processes - [Sampling Results)] Source View d j'hu Sep 2913:40:20 2005 WARNING: Synfcof information is not available for this range. Basic block identifiers w» appear instead of function names. Fa Help, press Fl Рис. 10.2. Процесс простоя системы в анализаторе VTune для задачи простоя системы, что бесполезно, поскольку вам нужно знать, почему система выполняет процесс простоя, а не рассматривать команды ассемблера, составляющие процесс простоя системы. Однако переключение на функцию Counter Monitor может дать некоторые подсказки. Функция Counter Monitor располагает собранные отсчеты по времени и позволяет увеличить масштаб по конкретному промежутку времени (как показано на рис. 10.3). Когда промежуток выбран (см. рис. 10.3), вы можете увеличить его для получения гистограммы, содержащей только те отсчеты, которые были собраны в течение выбранного промежутка времени. Обычно путем изучения выборки можно определить, что именно делало приложение. Ищите в приложении циклы, которые вызывали функции операционной системы, такие как чтение с диска, сетевой доступ и вызовы синхронизации — любую функцию, которая может ждать. Когда пример на рис. 10.3 был исследован таким образом, оказалось, что переход от интенсивного к слабому использованию процессора произошел после завершения компиляции множества модулей и перехода к редактированию связей, поскольку редактор связей выполняет множество операций обращения к дискам для чтения объектных файлов. Функция построения графа вызовов (Call Graph) позволяет сузить круг функций, которые вызывают ожидание. В анализаторе VTune можно попробовать выделить 10 функций с самым большим собственным временем ожидания (Тор 10 Self Wait
154 Глава 10 • Медленные операции HFte E<* tfew flctMty Configure Stfndow belp % иг 3* *^ % VTPfojec»3 ■ «| Activity2 (Counter Monitor) - *£ Try 1(189) • ;tt Counter Monitor Results (190) t !$ Serving R«utj (191) ^^ari^Qa у ъ &->* ниш ptf a a Counter Monitor Logged data 90.00 80.00 70.00 60.00 I 50.00 J 40.00 | 30.00 20.00 10 00 0.00 r f**^ *»**'** ^S и * \ * Time (MHseconds) l»«h8MpitrVwr«ail Hr>| Mix 15td.Pry. xLTotaQ : % Ida Time 1.0 40.954 0.000 89.950 41.797 Гиг Н«*л ore» PI Рис. 10.3. Функция Counter Monitor показывает переход от интенсивного к слабому использованию процессора Time), как показано на рис. 10.4. В данном случае выделенные функции находятся на критическом пути, скорее всего, они и являются источником простоя. Иногда чтобы удостовериться, что вы точно знаете, почему выполняется процесс простоя системы, необходимо провести эксперимент по производительности. Преднамеренно отключив в коде вызов подозреваемой функции, вы сможете определить, какие операции вызывают время простоя и сколько. Устранение проблем времени простоя напоминает ситуацию с системными вызовами. Сначала определите, почему функция ждет, а затем — как лучше исправить ситуацию: вызвать эту же функцию с другими аргументами, вызвать другую функцию или написать собственную функцию. Основные моменты При оптимизации медленных команд помните следующее: □ Команды и операции могут быть медленными потому, что предназначены для решения общих задач. Написание собственной функции, которая опирается
Основные моменты 155 Рис. 10.4. Граф вызовов с выделенными 10 функциями с самым большим собственным временем ожидания на конкретные знания о требованиях вашего приложения, может привести к весьма значительному росту производительности. □ Определите, как надо вызывать функции для достижения максимальной производительности. Размеры буферов и выравнивание данных обычно приводят к впечатляющим успехам. □ Используйте справочные таблицы, чтобы избежать медленных вычислений, особенно когда размеры таблиц небольшие, а количество предварительно подсчитанных значений велико. □ Когда вы видите, что работает процесс простоя системы, тщательно исследуйте причины с целью улучшения ситуации.
Операции с плавающей точкой До появления процессора Pentium операции с плавающей точкой выполнялись либо выделенным для этого сопроцессором, либо программным пакетом эмуляции операций с плавающей точкой. В любом случае использование чисел с плавающей точкой просто гарантировало, что приложение будет работать медленно. Однако эти дни давно ушли, и производительность операций с плавающей точкой сейчас не ниже производительности остальной части процессора, а в некоторых случаях даже выше. Тем не менее, те проблемы, которые влияют на все команды (такие как зависимости по данным, доступность портов выполнения, латентность памяти), влияют и на операции с плавающей точкой. В дополнение к обычным проблемам вы должны помнить о нескольких дополнительных проблемах, характерных только для операций с плавающей точкой; к ним относятся числовые исключения, контроль точности и преобразование чисел с плавающей точкой в целые. Для выполнения операций с плавающей точкой применяются команды блока операций с плавающей точкой (Floating-Point Unit, FPU) под названием х87, а также упакованные или скалярные команды с плавающей точкой, поддерживаемые потоковыми SIMD-расширениями (SSE-, SSE2- и ББЕЗ-команды); кроме того, возможно непосредственное манипулирование сохраненными числами с плавающей точкой при помощи целочисленных команд. Каждый метод имеет свои достоинства в отношении производительности, свои возможности и свои проблемы, что и является предметом обсуждения в данной главе. Числовые исключения FPU-блок х87 и 85Е/88Е2/55ЕЗ-команды в качестве реакции на определенные входные данные и условия вычисления могут порождать исключения. Процессор обрабатывает исключения, вызывая программные обработчики, а при маскировании
Числовые исключения 157 исключений игнорирует их и делает что-нибудь разумное, например, создает денор- мализованное число. Важно обнаруживать и устранять исключения с плавающей точкой, поскольку они обычно означают наличие ошибок и почти всегда снижают производительность. В табл. 11.1 приводится список всех возможных исключений с плавающей точкой. Таблица 11.1. Список исключений с плавающей точкой Исключение Stack Overflow or Underflow Invalid Operation Divide-by-zero Denormal Operand Numeric Overflow/ Numeric Underflow Inexact-result/ Precision Описание Попытка загрузки в непустой регистр. Всегда означает критическую ошибку1 Попытка использовать байты данных, которые не представляются числом с плавающей точкой и называются нечислом (Not-A-Number, NaN). Вызывается также неправильным применением бесконечности или отрицательных операндов. Всегда означает критическую ошибку Попытка деления на нуль. Всегда означает критическую ошибку Происходит при использовании критически малых чисел, которые не могут быть представлены в стандартном нормализованном формате с плавающей точкой. Наличие ненормализованных операндов всегда означает потерю точности и обычно требует исправления ситуации Имеет место, когда округленный результат операции превосходит самое большое или самое маленькое возможное конечное значение, которое может разместиться в целевом формате. Это состояние обычно может быть исправлено путем масштабирования значений, использования более высокой точности или сброса результата в нуль Происходит, когда результат операции представим в целевом формате не точно. Это единственное исключение, которое можно игнорировать Обнаружение исключений с плавающей точкой завершается путем сброса маски (битов) исключений в управляющем слове операций с плавающей точкой. На рис. 11.1 показана диаграмма значений битов для управляющего слова FPCW FPU- блока х87, а на рис. 11.2 — диаграмма для SSE-регистра управления и состояния MXCSR. Это относится только к FPU-блоку х87, в основе которого лежит стек. Основой всех SSE- команд являются регистры.
158 Глава 11 • Операции с плавающей точкой Управление бесконечностью Управление округлением Управление точностью 15 14 13 12 X 11 10 RC 9 8 PC 7 6 5 Р М 4 и м 3 о м 2 7 М 1 Г) м 0 I I м Маски исключений Точность - Потеря значимости. Переполнение Деление на нуль - Денормализованный операнд - Недопустимая операция - 1 Зарезервировано Рис. 11.1. Управляющее слово FPCW в FPU-блоке х87 31 16 15 14 13 1211 10 9 8 7 6 5 4 3 2 10 Зарезервировано F Z R С Р м и м О м Z м D М I м D А Z Р Е и Е О Е Z Е D Е I I Е, Сброс в нуль Управление округлением Маска точности Маска потери значимости Маска переполнения Маска деления на нуль Маска денормализованной операции Маска недопустимой операции Маска недопустимой операции1 Флаг точности Флаг потери значимости Флаг переполнения Флаг деления на нуль Флаг денормализованной операции Флаг недопустимой операции - 1 Флаг приравнивания денормализованных чисел нулю был введен в процессоре Pentium 4 Рис. 11.2. Регистр управления и состояния MXCSR Если бит установлен (равняется единице), тогда исключение считается замаскированным и не происходит. Когда бит сброшен (равняется нулю), исключение происходит. Хорошим способом определить, нет ли каких-либо исключений, является демаскирование всех исключений при запуске приложения, а затем
Числовые исключения 159 выполнение теста производительности и тестов контроля качества. Простейшим способом демаскирования исключений является вызов одной из следующих функций: HORD UnmaskAllx87FPExceptions (void) { WORD OldCtrl: WORD NewCtrl; _asm { FSTCW OldCtrl mov ax, OldCtrl and ax, OffcOh mov NewCtrl, ax FLDCW NewCtrl } return OldCtrl; } DWORD UnmaskAllSSEFPExceptions (void) { DWORD OldCtrl; DWORD NewCtrl; , _asm { STMXCSR OldCtrl mov eax,OldCtrl and eax, 0ffffe07fh mov NewCtrl, eax LDMXCSR NewCtrl } return OldCtrl; } Обе эти функции возвращают исходное значение регистра управления для последующего использования при восстановлении его значения. Механизм, который сообщает об исключениях с плавающей точкой, зависит от операционной системы. В Windows может появиться диалоговое окно Application Error (рис. 11.3), или исполняемый файл может просто запустить необработанное исключение. В Linux исключение, скорее всего, выдаст сигнал. В обоих случаях при выполнении под управлением отладчика можно остановиться на команде, запустившей исключение, что позволит понять проблему и исправить код так, чтобы исключение больше не возникало.
160 Глава 11 • Операции с плавающей точкой FPUmasks.ene - Application Error О The exception Floating-point overflow. (0xc000009i) occurred in the application at location 0x0040d773. Click on OK to terminate the program Click on CANCEL to debug the program OK Cancel Рис. 11.3. Пример диалогового окна Application Error Сброс в нуль и приравнивание денормализованных чисел нулю Когда числа с плавающей точкой становятся очень малыми, и нормализованный формат больше использовать нельзя, процессор создает соответствующее денормализованное число. Денормализованные числа всегда означают потерю точности, потерю значимости и чаще всего ошибку (или, по крайней мере, нежелательное состояние). Денормализованные числа могут использоваться как входные данные для последующих арифметических операций, но ценой потери производительности. Начиная с процессора Pentium III, в регистре MXCSR появился флаг FTZ (сброс в нуль). Когда он установлен, денормализованные результаты SSE/SSE2/SSE3- команд становятся равными нулю. В процессоре Pentium 4 в регистр MXCSR был добавлен флаг DAZ (приравнивание денормализованных чисел нулю). Этот флаг приводит к тому, что входные денормализованные операнды SSE/SSE2/SSE3- команд становятся нулевыми. Два эти флага вместе взятые ценой небольшой потери точности позволяют обойти все дорогостоящие издержки, связанные с обработкой денормализованных чисел. Оба режима (FTZ и DAZ) несовместимы с IEEE- стандартом 754, но поддерживаются с целью повышения производительности при работе со значениями, настолько близкими к нулю, что их обработка как нулевых не приведет к заметному изменению качества результата. Следующая функция устанавливает флаги FTZ и DAZ в регистре MXCSR: DWORD XMM SetFTZDAZ (void) DWORD old_mxcsr_val, new_mxcsr_val; _asm { STMXCSR old_mxcsr_val mov eax, old_mxcsr_val // сброс в нуль - бит 15 // потеря значимости маски - бит 11
Точность 161 // приравнивание денормализованных чисел нулю - бит 6 ог еах. 08840п mov new_mxcsr_val. еах LDMXCSR new_mxcsr_val } return old_mxcsr_val; } Изменение значения регистра управления MXCSR — это дорогостоящая операция, которая приводит к останову процессоров Pentium 4 и Pentium M, поэтому изменение значения регистра MXCSR не следует делать часто. Обычно значение регистра MXCSR меняется в начале выполнения приложения, а затем приложение работает, не меняя это значение. Точность Числа с плавающей точкой хранятся в памяти в одном из трех форматов: одинарной точности (4 байта), двойной точности (8 байт) и расширенной двойной точности (10 байт). Независимо от используемого формата, х87-команды с плавающей точкой всегда выполняют вычисления в глобальном режиме управления точностью, указанном в управляющем слове FPCW. Режимы, которые можно указать, называются так же: одинарной точности, двойной точности и расширенной двойной точности. В наборе SSE-, SSE2- и SSES-команд точность вычислений определяется выбранной командой, поддерживаются только одинарная и двойная точность. Быстрым способом повысить производительность является снижение точности вычислений с плавающей точкой, что влияет на производительность деления и извлечения квадратного корня. Табл. 11.2 иллюстрирует разницу в производительности при различной точности деления на процессоре Pentium 4. Таблица 11.2. Латентность в тактах команды деления с плавающей точкой Команда Команда х87-деления FDIV Команда скалярного деления DIVSS/DIVSD Команда упакованного деления DIVPS/ DIVPD Одинарная 23 22 32 Двойная 38 35 62 Расширенная двойная 43 - - Как видите, одинарная точность занимает меньше времени, чем двойная, а двойная — меньше, чем расширенная двойная. То же самое относится и к процессору Pentium M.
162 Глава 11 • Операции с плавающей точкой В первую очередь для повышения производительности операций плавающей точкой необходимо выяснить, какая нужна точность. Точность определяет максимальное значение числа, а также количество знаков, которое может быть представлено. На рис. 11.4 представлена диаграмма, показывающая, как числа с плавающей точкой отображаются на вещественные числа. Подмножество двоичных вещественных чисел, которые могут быть представлены форматом IEEE с плавающей точкой одинарной точности (32 бита) -100 -10 -10 1 10 100 ■ ■■■■■■■■ I---------I I I I I **99— -99-»- Рис. 11.4. Представление чисел с плавающей точкой одинарной точности Процессоры IA-32 при обработке и сохранении чисел с плавающей точкой поддерживают 32, 64 и 80 бит точности. Выбирайте самую низкую точность, удовлетворяющую вычислительные потребности вашего приложения. Самое большое и самое малое значения, представимые каждым из типов данных, показаны в табл. 11.3. Таблица 11.3. Длина и диапазон значений типов данных с плавающей точкой Тип данных Одинарная точность Двойная точность Расширенная двойная точность Длина 32 64 80 Приблизительный нормализованный десятичный диапазон От 1,18 х К)"38 до 3,4 хЮ38 От 2,23 х 10"308 до 1,79 х 10308 От 3,37 х Ю-4932 до 1,18 х 104932
Точность 163 После того как вы определили минимально требующуюся вам точность, монете соответственно изменить слово FPCW. По умолчанию в Linux используется расширенная двойная точность, в то время как в Windows — двойная. Тип данных в памяти не зависит от внутренней точности процессора. Объявление переменной с одинарной точностью вместо двойной не изменит точность вычислений, когда вычисления выполняются FPU-блоком х87. Однако когда компилятору разрешено использовать SSE-, SSE2- и 88ЕЗ-команды (как при создании кода под набор команд архитектуры Intel EM64T) или указан флаг -QxP/ -xP, то выбор типа данных влияет на то, какие SSE-, SSE2- и SSES-команды (одинарной или двойной точности) будут использоваться в коде. Это может привести к существенной разнице в производительности порожденного компилятором кода, а также существенно повлиять на способности компилятора в плане векторизации кода (и соответственно, повышения его производительности). Другой фактор, который необходимо учесть при выборе типа данных, состоит в том, что меньший по размеру тип данных одинарной точности допускает векторизацию с использованием четырех векторных элементов на регистр, в то время как тип двойной точности — только двух. Поэтому ускорение, достижимое при векторизации алгоритмов с данными одинарной точности, примерно вдвое превышает ускорение, достижимое путем векторизации того же алгоритма с данными двойной точности. Прочие факторы, которые стоит учитывать: объявленные типы данных определяют, сколько памяти занимают структуры данных, и таким образом напрямую влияют также и на используемую ширину полосы пропускания памяти, и на поведение кэша данного алгоритма. Очевидно, что в ваших интересах выбирать ровно такую точность, какая необходима для основных типов данных и вычислений (в соответствии с требованиями оптимизируемого алгоритма). В табл. 11.4 показаны значения битов поля управления точностью (PC) регистра FPCW в FPU-блоке Х87. Таблица 11.4. Поле управления точностью (биты 8 и 9) Х87-регистра FPCW Точность Одинарная точность Зарезервировано Двойная точность Расширенная двойная точность Поле PC оов 01В 10В ив ";'.', J Следующий код может быть использован для установки точности в х87-регистре управления операциями с плавающей точкой: #define PRECISIONJINGLE 0x0000 #define PRECISI0N_D0UBLE 0x0200 #define PRECISION EXTENDED 0x0300
164 Глава 11 • Операции с плавающей точкой WORD Setx87Precision(W0RD precision) { WORD OldCtrl; WORD NewCtrl; _asm { FSTCW OldCtrl mov ax, OldCtrl and ax, Ofcffh or ax, precision mov NewCtrl, ax FLDCW NewCtrl } return OldCtrl; } Перед тем как изменить точность, данная функция возвращает значение регистра управления. Важно восстановить это значение перед вызовом любых функций, которые зависят от точности операций с плавающей точкой, выбранной по умолчанию, или любых внешних функций, использующих или способных использовать в будущем операции с плавающей точкой. Изменение как регистра MXCSR, так и слова FPCW в FPU-блоке х87 — это дорогостоящая операция, поэтому не изменяйте точность х87-регистра для операций с плавающей точкой слишком часто. Следующую функцию можно применять для восстановления управляющего слова: WORD Setx87ControlWord(W0RD NewCtrlWord) { WORD OldCtrlWord; _asm { fnstcw OldCtrlWord fldcw NewCtrlWord } return OldCtrlWord; } Во всех командах с плавающей точкой, поддерживаемых потоковыми SIMD расширениями, для операций с одинарной и двойной точностью используютс разные форматы, поэтому для управления точностью нет необходимости из менять управляющее слово MXCSR. Компилятор по типам данных переменных обрабатываемых программой, автоматически определит, какие команды нужн задействовать.
Преобразование значения с плавающей точкой в целое, округление 165 Упакованный и скалярный режимы Большинство команд потоковых SIMD-расширений (SSE, SSE2 и SSE3) работает либо в упакованном режиме, когда операция применяется (в обычной для SlMD-команд манере) к отдельным элементам данных, упакованным в операндах источника и приемника, либо в скалярном режиме, когда операции применяются только к младшему элементу данных. В упакованном режиме для обозначения соответственно 32-разрядных операций одинарной точности и 64-разрядных операций двойной точности используются суффиксы ps и pd, а в скалярном — суффиксы ss и sd. На ассемблере упакованное деление с плавающей точкой записывается так: : четыре деления (каждое по 32 бит) одинарной точности DIVPS xmml. xmmO ; два деления (каждое по 64 бит) двойной точности DIVPD xmml, xmmO Скалярное деление с плавающей точкой записывается на ассемблере следующим образом: : одно деление (32 бит) одинарной точности DIVSS xmml, xmmO ; одно деление (64 бит) двойной точности DIVSD xmml, xmmO Скалярный режим может быть очень выгодным при смешивании вычислений с одинарной и двойной точностью в одной функции, поскольку он не требует менять управляющее слово (как это нужно делать при использовании FPU- блока х87). Компиляторы C++ и Fortran производства Intel поддерживают различные способы работы с командами потоковых SIMD-расширений (детали см. в главах 12 и 13). Преобразование значения с плавающей точкой в целое, округление Очень часто результат операции с плавающей точкой преобразуется к целому числу Это преобразование особенно характерно для приложений компьютерной графики, поскольку пикселы и координаты представляют собой целые числа, а вычисления иногда выполняются средствами арифметики с плавающей точкой. К сожалению, преобразование значения с плавающей точкой в целое может быть дорогостоящим. В языке С предписано, что при таком преобразовании, как показано в следующем коде, должно происходить усечение (округление в сторону нуля):
166 Глава 11 • Операции с плавающей точкой float a = 3.5f; int b; b • (int)a; // преобразование числа с плавающей точкой в целое // b будет равно 3 К сожалению, FPU-блок х87 по умолчанию округляет значения с плавающей точкой до ближайшего целого. Поэтому когда компилятор получает запрос на преобразование значения с плавающей точкой в целое, то слово FPCW изменяется дважды — один раз перед преобразованием и один раз после преобразования для восстановления исходного значения. Следующие четыре шага описывают процесс, выполняемый компилятором при преобразовании чисел с плавающей точкой в целые: 1. Сохранить управляющее слово операций с плавающей точкой. 2. Переключить управляющее слово в режим усечения. 3. Выполнить запись значения с плавающей точкой в целое (FISTP). 4. Восстановить режим округления в управляющем слове операций с плавающей точкой. Компилятор выполняет все четыре шага при каждом преобразовании, что потенциально является источником ненужных потерь. Однако все издержки можно ликвидировать, если разрешить компилятору использовать SSE-, SSE2- и SSE3- команды. Например, при указании флага -QxP/-xP все преобразования значений с плавающей точкой в целые можно выполнять при помощи команд CVTTSD2SI, CVTTSS2SI и FISTTP. Ни одна из этих команд не требует изменять слово FPCW, а это экономит много времени, особенно если преобразование значений с плавающей точкой в целые производится часто. Функции округления Функции f 1 оог и cei 1 часто используются для округления значения с плавающей точкой до ближайшего целого числа (при этом оно остается в формате с плавающей точкой). Часто реализация этих функций включает изменение режима управления округлением в регистре FCPW FPU-блока х87 или изменение управляющего регистра MXCSR. Изменение любого из этих регистров (если делать это часто) вызывает проблемы производительности, поэтому реализации cei 1 и f 1 оог, которым не нужно изменять эти регистры, могут дать значительный выигрыш в производительности. Библиотеки компиляторов C++ и Fortran производства Intel имеют оптимизированные подпрограммы f 1 оог и cei 1, которым не нужно изменять ни один из этих управляющих регистров. Тонкости обработки чисел с плавающей точкой Для приближенного выполнения некоторых вычислений с плавающей точкой имеется несколько хитрых приемов. Эти приемы следует использовать только тогда,
Тонкости обработки чисел с плавающей точкой 167 ^огда допустима меньшая точность, операции с плавающей точкой являются узким местом, а возможный прирост производительности имеет существенное значение, {(роме того, необходимо внимательно разобраться в деталях кода, генерируемого компилятором, поскольку описываемые приемы обычно не позволяют модулю векторизации оптимизировать ваш код. Применение этих приемов, когда компилятор уже выполнил векторизацию и оптимизацию кода, часто приводит не к росту, а к падению производительности. Преобразование значения с плавающей точкой в целое Когда значение с плавающей точкой уже находится в памяти, с его битами можно работать напрямую при помощи целочисленных команд. Следующий код может использоваться для приблизительного преобразования положительного значения с плавающей точкой одинарной точности в целое число (с округлением в сторону отрицательной бесконечности): #define FL0AT_FT01_MAGIC_NUM (float)(3«21) #define IT_FTOI_MAGIC_NUM 0x4ac00000 int FastFloatToIntCfloat f) { f += FLOAT_FTOI_MAGIC_NUM; return (*((int *)&f) - IT_FT0IJ1AGIC_NUM) »1; } Обратите внимание, что в данном случае при преобразовании не происходит отбрасывание значений, меньших нуля. Например, для числа -3,5 возвращается целое значение -4 (а не -3, как в языке С или C++). Извлечение квадратного корня Функция извлечения квадратного корня выдает приближенное значение для положительных чисел примерно с 5-процентной ошибкой, например вызов FastSqrt(144.0) возвращает 12,5. float FastSqrt (float f) { int t = *(int *)&f; t -= 0x3f800000; t »= 1; t += 0x3f800000; return *(float*)&t; }
168 Глава 11 • Операции с плавающей точкой Получение обратного значения от квадратного корня Функция получения обратного значения от квадратного корня выдает приближенное значение для чисел, больших 0,25, с ошибкой менее 0,6 %: float FastlnvSqrt (float x) { int tmp = ((0x3f800000 « 1) + 0x3f800000 - *(long*)&x) » 1; float у = *(float *)&tmp; return у * (1.47f - 0.47f * x * у * у); } При использовании всех перечисленных приемов обязательно проводите тесты производительности, проверяя, что ваш код действительно выполняется быстрее. В некоторых случаях операции записи в память и загрузки из памяти, выполняемые в этих приемах, могут перевесить любой выигрыш в производительности, полученный этими методами сокращенных вычислений. Основные моменты При работе с числами с плавающей точкой помните следующее: □ Избегайте издержек, связанных с исключениями и денормализованными числами. □ Используйте самый меньший тип данных с плавающей точкой, который обеспечивает требуемую точность, чтобы добиться лучшей векторизации и уменьшить требуемую пропускную способность памяти. □ Разрешите компилятору (с помощью соответствующих флагов) использовать SSE-, SSE2- и SSES-команды с целью оптимизации преобразований значений с плавающей точкой в целые.
Технология SIMD Технология SIMD (Single Instruction, Multiple Data — одна команда, несколько источников данных) является важным средством повышения производительности процессоров архитектуры Intel, начиная от процессора Intel Pentium с поддержкой технологии ММХ. С того времени во всех 32-разрядных процессорах архитектур IA и ЕМ64Т поддержка технологии SIMD постоянно расширялась. Типичная SIMD-команда обеспечивает более высокую производительность благодаря одновременной обработке нескольких источников данных (как показано на рис. 12.1). Источник 1 ХЗ Источник 2 I й Y3 Г Приемник X3 0PY3 —-^~ Х2 i Y2 f Х2 0PY2 Х1 ч Y1 i Г X10PY1 хо ч Y0 f X0OPY0 | Рис. 12.1. Модель выполнения SIMD-команды Краткую историю развития технологии SIMD от 8-байтных упакованных целых в технологии ММХ до 16-байтных чисел с плавающей точкой и упакованных целых в SIMD-расширениях (SSE, SSE2 и SSE3) иллюстрирует табл. 12.1. В данной главе представлены технология ММХ и потоковые SIMD-pac- ширения (Streaming SIMD Extensions, SSE), а также описывается несколько вариантов использования технологии SIMD с целью достижения более высокой
170 Глава 12 • Технология SIMD производительности. Вы можете найти подробные описания конкретных команд в руководстве для разработчика [18, 19, 20]. Таблица 12.1. Краткая история развития технологии SIMD Технология ммх ! SSE SSE2 SSE3 SSE3 на ЕМ64Т Первое применение Процессор Pentium с поддержкой технологии ММХ Процессор Pentium III Процессор Pentium 4 Процессор Pentium 4 с поддержкой гиперпоточности Процессоры архитектуры' ЕМ64Т Описание Появились упакованные 8-байтные целые Добавились 16-байтные упакованные числа с плавающей точкой одинарной точности Добавились 16-байтные упакованные числа с плавающей точкой двойной точности и 6-байтные упакованные целые Добавились некоторые команды к SSE2- командам Увеличилось количество SIMD-регистров с 8 до 16 Знакомство с технологией SIMD В технологиях ММХ и потоковых SIMD-расширениях используются широкие тракты данных и функциональные блоки современных процессоров для одновременной обработки узких трактов данных у упакованных элементов данных, то есть относительно коротких векторов, которые находятся в памяти или регистрах. Технология ММХ™ 64-разрядную технологию ММХ (MultiMedia extensions — мультимедийные расширения), поддержка которой появилась на процессоре Pentium, составили следующие расширения: □ Восемь 64-разрядных регистров: от mmO до mm7. □ Четыре 64-разрядных целых типа: ■ восемь упакованных байтов (8x8 бит); ■ четыре упакованных слова (4x16 бит); ■ два упакованных двойных слова (2 х 32 бит); ■ одно учетверенное слово (1 х 64 бит). □ Команды, работающие с 64-разрядными типами данных. Восемь 64-разрядных регистров эквивалентны наименее значимым частям стека регистров данных (с R0 по R7) блока операций с плавающей точкой (FPU-блока х87). Такое совпадение имен делает технологию ММХ прозрачной для операционной
Знакомство с технологией SIMD 171 системы, поскольку команды, которые сохраняют и восстанавливают состояние FPU-блока х87 во время переключения контекста, сохраняют и восстанавливают также ММХ-регистры. К сожалению, такое двойное использование подразумевает, что ММХ-команды и код FPU-блока х87 не могут легко смешиваться на уровне команд. Каждый кодовый модуль FPU-блока х87 должен выходить с пустым стеком FPU-блока х87, а для очистки регистра тегов после каждого блока ММХ-команд должна присутствовать команда emms. Все остальные ММХ-команды заполняют весь регистр тегов и очищают поле верхушки стека (Top-Of-Stack, TOS) в регистре состояния, что вызывает непредсказуемые результаты последующих команд FPU-блока х87. Технология ММХ поддерживает работающие с 64-разрядными целыми типами данных команды, в том числе команды перемещения данных, арифметические и логические команды, команды сравнения и сдвига. Некоторые из арифметических команд обрабатывают упакованные элементы данных с использованием обычной арифметики округления, когда отдельные результаты, превосходящие диапазон соответствующего типа данных, округляются путем усечения результата до менее значимых битов. В других арифметических командах применяется арифметика насыщения, когда отдельные результаты, которые иначе должны были бы округляться, урезаются до предельного значения соответствующего типа данных (например, при обработке упакованных байтов без знака выражение Oxfa + 0x08 урезается до Oxff вместо округления до значения 0x02). Потоковые SIMD-расширения Поддержка команд для обработки упакованных чисел с плавающей точкой одинарной точности была впервые введена на процессоре Pentium III в 128-разрядных SSE-командах. Эта поддержка была далее расширена в процессоре Pentium 4 набором 128-разрядных 55Е2-команд, среди которых были команды для обработки упакованных чисел с плавающей точкой двойной точности и упакованных целых, а в процессоре Pentium 4 с поддержкой гиперпоточности появился набор 128-разрядных SSES-команд с дополнительными возможностями по обработке комплексных чисел. Наконец, процессоры с поддержкой технологией ЕМ64Т имели расширенный набор SIMD-регистров. Все вместе взятые, эти технологии состоят из следующих расширений: □ Новые 128-разрядные регистры от xnmO до xmm7 (процессоры архитектуры IA-32) или от xmmO до xmml5 (процессоры архитектуры ЕМ64Т). □ Два 128-разрядных типа данных с плавающей точкой и пять 128-разрядных целых типов данных: • четыре упакованных числа с плавающей точкой одинарной точности (4 х 32 бит); • два упакованных числа с плавающей точкой двойной точности (2 х 64 бит); • шестнадцать упакованных байтов (16x8 бит);
172 Глава 12 • Технология SIMD • восемь упакованных слов (8x16 бит); • четыре упакованных двойных слова (4 х 32 бит); • два упакованных учетверенных слова (2 х 64 бит); • одно дважды учетверенное слово (1 х 128 бит). □ Команды для обработки 128-разрядных типов данных. Использование технологии SIMD Технология SIMD способна дать значительный выигрыш в производительности, но в языках С, C++ и Fortran нет прямых способов использования SIMD-команд. В прошлом единственным выходом был ассемблер, а разработку написание кода на ассемблере означало дополнительные трудозатраты на разработку, отладку и обслуживание. К счастью, в компиляторах C++ и Fortran производства Intel (а также в многих других) появились расширения, которые значительно облегчают применение SIMD-команд. На рис. 12.2 представлены четыре варианта использования технологии SIMD при помощи компилятора C++ производства Intel; эти варианты разъясняются в последующих разделах данной главы. Компилятор Fortran производства Intel поддерживает только автоматическую векторизацию. Автоматическая векторизация Классы C++ Внутренние команды Представляемый ассемблер Простота использования и понимания Лучшая управляемость Рис. 12.2. Различные варианты использования технологии SIMD Автоматическая векторизация Компиляторы C++ и Fortran производства Intel способны анализировать циклы в приложении в поисках возможностей использования SIMD-команд. Это свойство называется автоматической векторизацией. В операционной системе Windows любой из следующих флагов включает автоматическую векторизацию для потоковых SIMD-расширений: -Q[a]x{K | N | В | Р}. Рассмотрим в качестве примера файл исходного кода quarter.cpp, который содержит следующую функцию quarter(), предназначенную для сдвига вправо на два бита всех элементов целочисленного массива: // Исходная версия на стандартном языке C++ void quarterdnt array[], int len) { for (int i = 0; i < Ten; i++) {
Использование технологии SIMD 173 array[i] = array[i] » 2; } } При компиляции этого примера, как показано далее, появляется диагностическое сообщение, говорящее о том, что автоматическая векторизация прошла успешно: => icl -QxP -с -Fa quarter.cpp quarter.срр(З) : (col. 3) remark: LOOP WAS VECTORIZED. Исследование сгенерированного ассемблерного файла quarter.asm показывает, что компилятор производства Intel автоматически преобразовал последовательный цикл в SIMD-команды. Вот небольшая часть сгенерированного ассемблерного файла: $В1$10: movdqa xmmO, XMMWORD PTR [edi+edx*4] psrad xmmO, 2 movdqa XMMWORD PTR [edi+edx*4]. xmmO add edx, 4 cmp edx, ecx jb $B1$10 Обратите внимание, что компилятор производства Intel использует команду перемещения выровненных данных movdga для загрузки и сохранения упакованных целых чисел в векторном цикле, несмотря на отсутствие информации относительно выравнивания массива в памяти. Здесь не виден явно другой вариант оптимизации — динамическая очистка цикла. В данном примере цикл сначала выполняется последовательно до тех пор, пока эталоны доступа не выровняются надлежащим образом, что позволит использовать эти более эффективные команды. Более подробное объяснение автоматической векторизации можно найти в главе 13. Библиотеки классов C++ Компилятор C++ производства Intel поставляется с библиотеками классов C++, которые определяют несколько типов данных, напрямую использующих SIMD- команды. Для применения этих типов просто подключите один из следующих заголовочных файлов (эта функциональная возможность не поддерживает SSE3- команды): #include <ivec.h> // ММХ #include <fvec.h> // SSE (нужен также заголовочный файл ivec.h) #inc"lude <dvec.h> // SSE2 (нужен также заголовочный файл fvec.h)
174 Глава 12 • Технология SIMD После этого вместо автоматической векторизации можно задействовать эти типы данных, чтобы иметь больший контроль над скомпилированным кодом. В табл. 12.2 перечислены все допустимые обобщенные типы данных. Чтобы лучше различать упакованные целые со знаком и без знака, после начальной буквы I в имени класса указывается символ s или и, как в именах Isvec8 и Iuvec8. Таблица 12.2. Типы данных SIMD-расширений при использовании библиотек классов C++, поставляемых с компилятором C++ производства Intel Тип данных Целые Числа с плавающей точкой одинарной точности Числа с плавающей точкой двойной точности Размер и количество (8 или 16) х 8 бит (4 или 8) х 16 бит (2 или 4) х 32 бит (1 или 2) х 64 бит 1x128 бит 4 х 32 бит 2 х 64 бит Ключевое слово I8vec8,18vecl6 I16vec4,116vec8 I32vec2,132vec4 I64vecl, I64vec2 I128vecl F32vec4 F64vec2 Для преобразования последовательного цикла в SIMD-команды просто объявите все переменные (к которым обращаетесь) с желаемыми типами, а затем уменьшите количество циклов на количество элементов, обрабатываемых за один раз. Следующая версия функции quarter() иллюстрирует этот процесс для типа данных Is32vec4 (четыре упакованных 32-разрядных целых со знаком): #include <dvec.h> // Модифицированная версия для типа данных Is32vec4 void quarterVecdnt array[], int len) { // подразумевается, что значение len кратно 4 // подразумевается, что массив выровнен по границе 16 байт Is32vec4 *array4 = (Is32vec4 *) array: for (int i = 0; i < len/4; i++) { array4[i] = array4[i] » 2; } } Внутренние команды компилятора Компилятор C++ производства Intel поддерживает внутренние команды, напрямую отображающиеся на SIMD-команды (так же как и на многие другие команды ассемблера). Для включения внутренних команд, относящихся к технологии SIMD, просто подключите один из следующих заголовочных файлов:
Использование технологии SIMD 175 ^include <mmintrin.h> // ММХ ^include <xmmintrin.h> // SSE (нужен также заголовочный файл mmintnn.h) ^include <emmintrin.h> // SSE2 (нужен также заголовочный файл xmmintnn.h) ^include <pmmintrin.h> // SSE3 (нужен также заголовочный файл emmintrin.h) Типы данных ml28, ml28d и ml28i определяют, соответственно, упакованное число с плавающей точкой одинарной точности, упакованное число с плавающей точкой двойной точности и любое упакованное целое число. В следующем примере показана другая версия функции quarterC), которая использует внутренние команды для реализации операций сдвига упакованных целых чисел: finclude <emmintnn.h> // Модифицированная версия, использующая // внутренние команды компилятора void quarterlntrinsic(int array[], int len) { // подразумевается, что значение len кратно 4 // подразумевается, что массив выровнен по границе 16 байт ml28i *array4 = ( ml28i *) array; for (int i = 0; i < len/4; i++) { array4[i] = _mm_srai_epi32(array4[i], 2); Внутренние команды напоминают язык ассемблера, за исключением того, что работу по фактическому выделению регистров, планирование команд и режимы адресации они оставляют компилятору. Если не считать явно невыровненных внутренних команд загрузки и сохранения, таких как _mm_l oadu_si 128() и _mm_storeu_ si 128(), компилятор предполагает, что упакованные операнды внутренних команд должным образом выровнены. То есть приведенный ранее код соответствует таким командам ассемблера: $В1$3: movdqa psrad movdqa add cmp jb xmmO, XMMWORD PTR [edx] xmmO, 2 XMMWORD PTR [edx], xmmO edx. 16 edx, eax $B1$3 Поскольку многие детали оставляются компилятору, пользоваться внутренними командами проще, чем языком ассемблера, но возможностей контролировать генерируемые компилятором команды меньше. Объявление переменной как ml28i xmmO не влияет на выделение регистров, поскольку имя для компилятора является просто идентификатором. Если требуется полный контроль над генерируемыми
176 Глава 12 • Технология SIMD командами, вам придется прибегнуть к подставляемому ассемблеру, как объясняется в следующем разделе. Подставляемый ассемблер Компилятор C++ производства Intel поддерживает также подставляемый ассемблер, что обеспечивает возможность писать предельно низкоуровневый код. В следующем примере представлена еще одна версия функции quarter(), написанная на подставляемом ассемблере: // Модифицированная версия на подставляемом ассемблере void quarterAsmdnt аггау[]. int len) { // подразумевается, что значение len кратно 4 // подразумевается, что массив выровнен по границе 16 байт _asm { mov esi, array mov ecx, len shr ecx, 2 loop: movdqa xmmO, [esi] psrad xmmO, 2 movdqa [esi], xmmO add esi, 16 sub ecx, 1 jnz loop esi - указатель массива ecx - счетчик цикла количество проходов = len / 4 загрузить 4 целых сдвинуть 4 целых сохранить 4 целых переместить указатель массива декрементировать счетчик цикла Достоинства и недостатки четырех вариантов использования технологии SIMD С ростом управляемости растут и возможности, связанные с повышением производительности, однако происходит это за счет дополнительных трудозатрат. Автоматическая векторизация является самым простым вариантом использования технологии SIMD. Однако несмотря даже на постоянное совершенствование компиляторов производства Intel, этот вариант не всегда дает желаемый рост производительности. В этом случае для повышения производительности в некоторых важных горячих точках вашего приложения может оказаться полезным применение одного из оставшихся вариантов. В любом случае, не думайте, что максимальная производительность требует программирования на ассемблере, поскольку компиляторы часто прибегают к многочисленным вариантам оптимизации, которые вы можете пропустить. Лучше всего выбрать самый простой вариант, обеспечивающий желаемые функциональные возможности, а затем путем анализа производительности определить, что еще требуется, дополнительная оптимизация или более сложная техника кодирования.
Использование технологии SIMD 177 В табл. 12.3 резюмируются некоторые достоинства и недостатки каждого варианта использования технологии SIMD, описанного в данной главе. Таблица 12.3. Сводка четырех вариантов использования технологии SIMD Вариант Ассемблер Внутренние команды компи- лятора Библиотеки классов C++ Автоматическая векторизация Описание Как при использовании подставляемого ассемблера, так и при использовании реального ассемблера используются настоящие команды языка ассемблера с указанием всего необходимого, включая регистры и режимы адресации Похожие на реальные команды ассемблера, встроенные функции задают команды, но не регистры или режимы адресации Используют типы данных, которые очень похожи на стандартные С и C++ Компилятор сам делает всю работу. Более подробно описывается в главе 13 Достоинства (+) и недостатки (-) + - + + - + + - + + — Непосредственное управление и доступ ко всем командам и регистрам Трудно читать, отлаживать, кодировать, изучать и поддерживать Код специфичен для определенного класса процессоров и должен переписываться для каждого нового поколения SIMD- команд Доступ ко всем командам без необходимости заниматься распределением регистров и режимами адресации Хорошо интегрируется с С и C++ Трудно читать, отлаживать, кодировать, изучать и поддерживать Код специфичен для определенного класса процессоров и должен переписываться для каждого нового поколения SIMD- команд Легко читать, отлаживать, кодировать, изучать и поддерживать Код не специфичен для какого-то одного класса процессоров Есть доступ не ко всем доступным комбинациям команд и типов данных Не нужно изменять исходный код, он остается простым для чтения и поддержки Будущие версии компиляторов смогут сами использовать достоинства каждого нового поколения SIMD-команд Компилятор не всегда способен выделить SIMD-команды из последовательного кода
178 Глава 12 • Технология SIMD Соображения по поводу технологии SIMD При использовании технологии SIMD всегда старайтесь в первую очередь задействовать автоматическую векторизацию (рекомендации см. в главе 13). Только в том случае, если такой подход не дает удовлетворительной производительности, следует попытаться вручную переписать код в горячих точках с применением SIMD-команд. Далее предлагается несколько важных соображений относительно использования технологии SIMD. Где имеет смысл использовать технологию SIMD Очевидно, что технологию SIMD в приложении можно использовать всегда и везде. Однако так же как и с любыми вариантами оптимизациями, усилия по переписыванию кода должны быть сосредоточены на тех частях приложения, которые дадут самый значительный эффект. Простое введение SIMD-команд во все точки приложения повышения производительности не гарантирует. Главная трудность, с которой вы можете столкнуться при использовании SIMD-команд — это обеспечение благоприятной с точки зрения технологии SIMD организации данных. При проектировании и написании приложения и отдельных его алгоритмов всегда заботьтесь об организации данных таким образом, чтобы добиться максимальной эффективности кэша и дружественности данных для технологии SIMD. Дружественные для SIMD данные выровнены в памяти надлежащим образом и с ними легко работать при помощи SIMD-команд (нет необходимости обращаться к отдельным частям регистра). Горячие в смысле расходования времени точки приложения — это главные кандидаты для вставки SIMD-команд. После выявления горячих точек при помощи какого-либо инструмента, такого как анализатор производительности VTune, необходимо проанализировать каждую найденную точку с целью определить, к каким данным выполняется обращение. Исходя из объема, организации и выравнивания данных, а также выполняемых с ними вычислений, вы сможете определить, имеет ли смысл задействовать SIMD- команды. Ищите те места, в которых есть возможность модифицировать данные, чтобы сделать более эффективным использование SIMD-команд. Выравнивание памяти Хранимые в памяти операнды SIMD-команд для достижения максимальной производительности должны быть надлежащим образом выровнены. Хранимые в памяти операнды должны быть выровнены, по крайней мере, по 8-байтной границе для технологии ММХ и по 16-байтной для потоковых SIMD-расширений. При обработке невыровненных данных SSE-, SSE2- или SSES-командами запускается исключение, если только не использовать специальных (и более медленных) команд для перемещения невыровненных данных. Механизм автоматической векторизации в компиляторах C++ и Fortran производства Intel становится все более совершенным, соответственно, совершенствуются и средства выравнивания памяти
Соображения по поводу технологии SIMD 179 компилятора. Часто можно обнаружить, что компилятор сам оптимизирует кадры стека и структуры данных в смысле выравнивания памяти. Тем не менее, есть три метода, позволяющие программисту влиять на выравнивание памяти. Во-первых, в языках С и C++ добавление перед объявлением конструкции decl spec(a 1 i gn(база, смещение)) означает, что для объявленной сущности предлагается выделять память, начиная с адреса а, который удовлетворяет условию: a mod база = смещение Смещение не обязательно, и по умолчанию оно устанавливается в нуль. База может быть любой небольшой степенью двойки. Например, программист может запросить 64-байтное выравнивание для массива следующим образом: declspec(align(64)) double a[N]; Во-вторых, путем заполнения составных структур можно разместить важные компоненты по выровненным адресам, как это показано в следующем объявлении структуры. Здесь принудительное 16-байтное выравнивание для переменной ххх делает выровненным также массив целых чисел, но приводит к нарушению выравнивания массива чисел с плавающей точкой: struct node { int x[7]; float a[4]; }: declspec(align(16)) struct node xxx; Вы можете легко исправить это нарушение выравнивания, введя в структуру дополнительную 4-байтную переменную dummy, которая никогда не используется: struct node { int x[7]; int dummy; // заполнение для выравнивания а[] float a[4]; }: В-третьих, вы можете обеспечить выравнивание динамически выделяемой памяти, управляя указателем, который возвращается стандартной С-библиотекой, или используя внутренние команды компилятора C++ производства Intel, предназначенные для выделения памяти. В следующих двух версиях выделяется 512 байт памяти с 16-байтной границы с помощью, соответственно, стандартной С-библиотеки и внутренних команд компилятора: // Версия 1: использование С-библиотеки времени выполнения int *pBuf, *pBufOrig; pBufOrig = (int *) malloc(128*sizeof(int)+15); pBuf = (int *H((int)pBufOrig * 15) & -OxOf);
180 Глава 12 • Технология SIMD free(pBufOrig); // Версия 2: использование внутренних команд компилятора int *pBuf = (int *) _mm_malloc(128*sizeof(int), 16); jTim_free(pBuf); Организация данных Проблема организации данных очень часто встречается при использовании SIMD- команд. Для иллюстрации рассмотрим скалярное произведение двух векторов Хи Y в следующем уравнении: [лг 1,лг2,лгЗ, jf 4] • Вот реализация этого скалярного произведения на языке С: float dot(float x[], float y[]) { return х[0]*у[0] + х[1]*у[1] + х[2]*у[2] + х[3]*у[3]; } j Очевидно, что выполнение четырех умножений может быть хорошим применением для SIMD-команд. Однако возникает одно осложнение — скалярное произведение требует складывать вместе все отдельные элементы регистра. Реализация такой операции, которая называется сквозным суммированием регистра, для большинства упакованных типов данных в виде одной команды не существует. Вы можете произвести сквозное суммирование регистра для чисел с плавающей точкой одинарной точности при помощи двух команд haddps или просто сохранить полученный вектор в памяти и сложить элементы как скаляры. Последний вариант представлен в следующем фрагменте кода, который является реализацией скалярного произведения в SIMD-командах (с помощью библиотек классов C++): #include <fvec.h> float dotVec(float x[], float y[]) { F32vec4 *pX - (F32vec4 *) x; F32vec4 *pY - (F32vec4 *) y; F32vec4 val = pX[0] * pY[0]; return val[0] + val[l] + val[2] + val[3]; } г/4 = x\y\ + xlyl + хЪуЪ + х4г/4
Соображения по поводу технологии SIMD 181 Сквозное суммирование регистра является медленной операцией и может поглотить весь выигрыш от использования SIMD-команд. Хитрость состоит в том, чтобы найти другой способ записывать данные, чтобы не нужно было ни сквозное суммирование регистра, ни применение других операций, которым необходим доступ к отдельным элементам SIMD-регистра. Решение появляется, если делать четыре скалярных произведения вместо одного. Предположим, что имеется восемь векторов А, В, С, Д W, X, Y и Z, и вы хотите вычислить скалярные произведения AW, BX, СУ и DZ. Вместо того чтобы хранить данные в восьми отдельных массивах, организованных в виде структуры массивов, вы можете чередовать данные, организовав массив структур, как показано на рис. 12.3. Структура массивов а1|а2|аЗ|а4|Ы |Ь2|ЬЗ|Ь4|с1 1с2|сЗ|с4 Id1 Id2!d3|d4 w1|w2|w3|w4|x1 |х2 |хЗ |x4 |y1 |y2|y3|y4|z1 |z2|z3|z4 Массив структур a1 w1 M x1 d y1 d1 z1 a2 w2 b2 x2 c2 y2 |d2 z2 a3 w3 ЬЗ|сЗ x3|y3 d3 z3 |a4l jw4] b4 x4 c4 У4 d4| z4 Рис. 12.3. Массив структур и структура массивов Функцию скалярного произведения для четырех векторных пар теперь можно записать лишь с помощью четырех SIMD-умножений и трех SIMD-сложений, чтобы получить четыре результата в векторном формате. Вот реализация этой идеи с помощью внутренних команд компилятора: #include <xmmintrin.h> _jnl28 dot4SIMD(_ml28 abcd[], _ml28 wxyz[]) { _ml28 tempi - _mm_mul_ps(abcd[0], wxyz[0]); _ml28 temp2 = _mm_mul_ps(abcd[l], wxyz[l]); _ml28 temp3 = _mm_mul_ps(abcd[2], wxyz[2]); _ml28 temp4 = _mm_mul_ps(abcd[3], wxyz[3]); tempi = _mm_add_ps(tempi, temp2); tempi = _mm_add_ps(tempi, temp3); return _mm_add_ps(tempi, temp4); Надлежащие выравнивание памяти и организация данных являются основными факторами повышения производительности при использовании SIMD-команд. Если все делается правильно, вы можете реализовать все достоинства технологии SIMD, касающиеся производительности. Если же что-то делается неправильно,
182 Глава 12 • Технология SIMD можно потерять большую часть выигрыша от применения технологии SIMD Время, затраченное на поиск правильных вариантов организации данных и выравнивания памяти, может окупиться двояко. Во-первых, может оказаться, что уже после автоматической векторизации удастся получить лучший код, и дополнительных усилий более не потребуется. Во-вторых, если даже для важных горячих точек понадобится некоторая ручная оптимизация, то улучшенные варианты организации данных и выравнивания памяти обычно упрощают дальнейшие усилия по преобразованию кода в SIMD-команды. Выбор подходящего упакованного типа данных По сравнению с традиционными векторными процессорами с фиксированной длиной вектора и шириной элемента данных, степень параллелизма в технологии SIMD меняется с изменением ширины отдельных элементов данных. В результате выбор самого «узкого» из допустимых типов данных (который соответствует задаче) обеспечивает максимально возможный параллелизм. Для иллюстрации этой идеи рассмотрим файл исходного кода sum.cpp, который содержит следующую функцию add() для суммирования всех элементов массива: unsigned short a[256]; unsigned int addО { unsigned int s = 0; for (int i = 0; i < 256; i++) { s += a[i]; } return s; } Компилятор C++ производства Intel автоматически выполняет векторизацию цикла в этой функции, где режим оптимизации, касающийся развертывания циклов, отключен для получения более компактного кода: => icl -QxP -QunrollO -с -Fa sum.cpp sum.cpp(5) : (col. 3) remark: LOOP WAS VECTORIZED. Далее следует сгенерированный ассемблерный файл sum.asm, снабженный для ясности некоторыми комментариями. Изучая его, вы можете видеть, что компилятор создал векторный цикл, который аккумулирует четыре частичных суммы в регистре xmmO, после чего идет код сквозного суммирования регистра: pxor xmmO, xmmO ; настройка аккумулятора pxor xmml, xmml ; настройка нулевого вектора хог еах, еах ; настройка цикла $В1$2: ;
Соображения по поводу технологии SIMD 183 movq punpcklwd paddd xmmO, add cmp jb movdqa psrldq paddd movdqa psrldq paddd movd xmm2. QWORD PTR xmm2, xmml xmm2 eax, 8 eax, 512 $B1$2 xmml. xmmO xmml. 8 xmmO. xmml xmm2. xmmO xmm2. 4 xmmO, xmm2 eax. xmmO a[eax] загрузить 4 коротких целых zero-ext 4 целых добавить 4 целых логика цикла просуммировать регистр xmmO В сгенерированном коде для большинства операций используется четырехка- нальный SIMD-параллелизм, что уже работает гораздо быстрее, чем эквивалентная скалярная реализация этого цикла. Однако предположим теперь, что только 16 младших битов результата нужны всем клиентам функции add() или что содержимое массива такое разреженное, что сумма никогда не выходит за пределы точности в 16 бит. В этом случае для аккумулятора будет достаточна более низкая точность, которая достигается простой модификацией исходного кода: unsigned int addО { unsigned short s = 0; // достаточно в 16 бит for (int i = 0; i < 256; i++) { s +- aril; return s; } Теперь код, полученный после автоматической векторизации, выглядит гораздо проще (за исключением кода сквозного суммирования регистра после цикла): $В1$2: рхог хог paddw add cmp jb movdqa psrldq xmmO, xmmO eax. eax xmmO, XMMWORD PTR a[eax] eax, 16 eax. 512 $B1$2 xmml, xmmO xmml, 8 настроить аккумулятор настроить индекс цикла добавить 8 коротких це логика цикла
184 Глава 12 • Технология SIMD paddw movdqa psrldq paddw movdqa psrldq paddw movd movzx xmmO, xmml xmm2, xmmO xmm2, 4 xmmO, xmm2 xmm3, xmmO xmm3, 2 xmmO, xmm3 edx. xmmO eax, dx просуммировать регистр xmmO Изменив одну строку исходного кода, мы дали возможность компилятору задействовать 8-канальный SIMD-параллелизм для большинства операций, что приводит к еще большему повышению производительности. Поскольку компиляторы очень педантично относятся к сохранению семантики оригинального последовательного алгоритма, решение о применении более «узких» типов данных часто может принять только программист. Как показал предыдущий пример, если помнить об этой возможности при написании кода, можно получить огромный прирост производительности. Совместимость вычислений с помощью SIMD-команд и команд FPU-блока х87 Соответствующие SIMD-команды и команды FPU-блока х87 обрабатывают типы данных с плавающей точкой как одинарной, так и двойной точности. Однако с этими типами данных SIMD-команды работают в «родном» формате (32 или 64 бита соответственно), а FPU-блок х87 расширяет их для выполнения вычислений до формата с плавающей точкой двойной точности (80 бит), а затем перед записью в память округляет результат, возвращаясь к формату одинарной или двойной точности. Таким образом, при выполнении одной и той же операции над одними и теми же значениями одинарной или двойной точности FPU-блок х87 может выдать результат, несколько отличающийся от результата выполнения SIMD-команд с плавающей точкой. Основные моменты При использовании технологии SIMD помните следующее: □ Технология SIMD, разработанная Intel, объединяет 64-разрядную технологию ММХ и 128-разрядные потоковые SIMD-расширения (SSE, SSE2 и SSE3). □ Компилятор C++ производства Intel поддерживает четыре варианта использования технологии SIMD: ■ автоматическая векторизация; ■ библиотеки классов C++;
Основные моменты 185 • внутренние команды компилятора; • подставляемый ассемблер. Компилятор Fortran производства Intel поддерживает только автоматическую векторизацию. О Всегда в первую очередь пробуйте задействовать автоматическую векторизацию, возможно, в сочетании с небольшими изменениями исходного кода. Только в том случае, если этот подход не дает удовлетворительной производительности, можно прибегнуть к ручному переписыванию кода в важных горячих точках с использованием SIMD-команд. □ Выбирайте самый «узкий» тип данных, позволяющий решить задачу. Это позволяет максимально задействовать параллелизм технологии SIMD. □ Помните о разнице в точности операций с плавающей точкой, которая может иметь место при выполнении команд FPU-блока х87 и SIMD-команд.
Автоматическая векторизация В предыдущей главе автоматическая векторизация упоминалась в качестве одного из четырех вариантов применения технологии SIMD. В данной главе подробно рассказывается о том, как с минимальными усилиями можно эффективно использовать автоматическую векторизацию в компиляторах C++ и Fortran производства Intel. Читатели, которых интересуют методики, лежащие в основе автоматической векторизации, могут обратиться к дополнительной литературе [6]. Параметры компиляторов для векторизации В данном разделе дается сводка тех параметров компиляторов, которые обычно используются в контексте векторизации с потоковыми SIMD-расширениями (SSE, SSE2 и SSE3). Поскольку эта сводка не является исчерпывающей, за полным списком обращайтесь к документации на компиляторы производства Intel [16]. Наиболее часто используемые параметры компиляторов В операционной системе Windows компилятор C++ производства Intel для процессоров IA-32 и ЕМ64Т запускается из командной строки следующим образом: => icl [параметры] источник.с Аналогично запускается и компилятор Fortran производства Intel: -> ifort [параметры] источнике В обоих случаях [параметры] — это список необязательных параметров компилятора. В Linux применяется похожий синтаксис, только названия компиляторов другие: ice и ifort соответственно. В табл. 13.1 перечислены параметры компилятора, касающиеся векторизации. 13
Параметры компиляторов для векторизации 187 Таблица 13.1. Параметры компиляторов для векторизации (С, C++ и Fortran) Windows -QxK или -QaxK -QxN или -QaxN -QxB или -QaxB -QxP или -QaxP -Qvec-report 0 1 2 3 Linux -xK или -ахК -xN или -axN -xB или -ахВ -xP или -ахР -vec-report 0 1 2 3 Семантика Генерировать код для процессора Pentium HI Генерировать код для процессора Pentium 4 Генерировать код для процессора Pentium M Генерировать код для процессора Pentium 4 с поддержкой гиперпоточности Уровень диагностики векторизации: — отключить диагностику векторизации — сообщать об успешной векторизации кода (по умолчанию) — то же, что и первый вариант, но дополнительно выдавать диагностику сбоев — то же, что и второй вариант, но дополнительно сообщать о препятствующих зависимостях по данным Для процессоров IA-32 любой из параметров -Qx{KNBP} (Windows) или -x{KNBP} (Linux) включает режим генерации кода (и следовательно, векторизации) с наборами команд для процессоров Pentium III, Pentium 4, Pentium M и Pentium 4 с поддержкой гиперпоточности соответственно. Для ЕМ64Т компилятор поддерживает только параметры -QxP и -хР. Необязательный префикс а в этих параметрах включает режим автоматической процессорной диспетчеризации. В этом случае компилятор получает указание произвести в программе поиск функций, которые могут получить выигрыш от оптимизации под конкретный процессор. Для каждой функции, где такая оптимизация кажется выгодной, компилятор генерирует две версии кода: обобщенную и версию для конкретного процессора. Во время выполнения программа выберет необходимую версию в соответствии с процессором, фактически используемым для выполнения программы. Таким образом, генерируемый двоичный файл обеспечит прирост производительности на современных процессорах и при этом сможет правильно работать на более старых процессорах. Параметры -Qvec-report<w> (Windows) и -vec-report<w> (Linux) управляют объемом диагностики, касающейся векторизации. Значение п = О полностью отключает диагностику, что позволяет провести компиляцию без выдачи сообщений. Значение п = 1 (по умолчанию) заставляет выводить сообщения обо всех фрагментах кода, векторизация которых прошла успешно. В каждом сообщении
188 Глава 13 • Автоматическая векторизация имеется название исходного файла с номерами строки и столбца первой инструкции во фрагменте с векторизованным кодом. Значения п = 2 и п = 3 заставляют выводить информацию о тех циклах программы, для которых векторизация выполнена не была, что может быть полезным, если сделать попытку лучше приспособить программу для векторизации. Информация, которую позволяют получить последние два параметра, может быть очень многословной, поскольку в нее входят результаты диагностики всех циклов программы, даже тех, которые вряд ли подаются векторизации. Эта диагностика в основном призвана содействовать переписыванию программы с целью получить больше возможностей для эффективной векторизации (как показано далее в этой главе). В табл. 13.2 представлены некоторые другие параметры компилятора, которые могут быть полезны при векторизации программ, написанных на языках С, C++ и Fortran. Параметры -Fa (Windows) и -S (Linux) генерируют ассемблерный файл, по которому программист может проверить качество генерируемых команд. Параметры -Qunrol 10 (Windows) и -unrol 10 (Linux) вообще отключают режим развертывания циклов, в том числе векторных. Программная предвыборка, которая используется в основном при компиляции для процессора Pentium III, отключается при помощи параметра -Qprefetch- (Windows) или -no-prefetch (Linux). Параметры -Qansi-alias (Windows) и -ansi -alias (Linux) позволяют компилятору опираться на предложенные ANSI правила совпадения имен, что может дать больше возможностей для векторизации в программах, соответствующих стандартам ANSI для языков программирования. Для Fortran этот параметр включен по умолчанию, а для С и C++ должен указываться явно. Таблица 13.2. Дополнительные полезные параметры (С, C++ и Fortran) Windows -Fa -QunrollO -Qprefetch- -Qansi-alias -Qrestrict I -Qc99 -Qsafe-cray-ptr Linux -S -unrollO -no-prefetch -ansi-alias -restrict -c99 -safe-cray-ptr Семантика Генерировать ассемблерный файл Отключить развертывание циклов Отключить программную предвыборку Включить правила ANSI относительно совпадения имен Включить режим использования ключевого слова restrict (С и C++) Включить расширения С99 (С) Включить безопасные указатели для компьютера Cray (Fortran) В таблицу включены также несколько специфичных для отдельных языков параметров. Параметры -Qrestrict (Windows) и -restrict (Linux) включают режим использования ключевого слова restrict в С и C++ с целью передачи
Параметры компиляторов для векторизации 189 компилятору информации об отсутствии совпадения имен у переменных- указателей (как обсуждается более подробно в следующем разделе). Параметры -Qc99 (Windows) и -с99 (Linux) включают расширения С99 для языка программирования С, такие как архивы переменной длины и встроенные типы данных для комплексных чисел одинарной и двойной точности (float _Complex и double Complex соответственно). Программа, которая использует эти типы комплексных данных, обычно позволяет выполнить более эффективную векторизацию комплексных операций, чем программа, применяющая пользовательские типы комплексных данных (вроде двух полей в структуре для хранения вещественной и мнимой частей комплексного числа). Наконец, параметры -Qsafe-cray-ptr (Windows) и -safe-crayptr (Linux) сообщают компилятору, что традиционные указатели Cray в языке Fortran не могут использоваться с другими переменными при назначении псевдонимов. Пример использования параметров компилятора Рассмотрим следующий файл исходного кода vec.c, в котором номера строк заданы в виде комментариев: /* 01 */ double a[100]: /* 02 */ void doit(void) { /* 03 */ int i; /* 04 */ for (i=0;i<100;i++) { /* 05 */ a[i] « a[i] * 7.0; /* 06 */ } /* 07 */ } При компиляции файла vec.c для Windows (как показано далее) выдается одно сообщение, касающееся диагностики векторизации (параметр -с отключает редактирование связей): => icl -QxP -с vec.c vec.c(4) : (col. 12) remark: LOOP WAS VECTORIZED. Это сообщение говорит об успешной векторизации цикла в строке 4. Данный формат совместим со средой разработки Microsoft Visual C++ .NET, где двойной щелчок на диагностическом сообщении в окне вывода перемещает фокус окна редактора на соответствующую строку в файле исходного кода. Программисты, которым необходимо проверить сгенерированные команды, могут воспользоваться флагом -Fa (Windows) или -S (Linux) для получения ассемблерного файла. Далее приведен пример работы под управлением Windows из командной строки (просмотр сгенерированных команд):
190 Глава 13 • Автоматическая векторизация => icl -Fa -QxP -QunrollO -c vec.c => type vec.asm xor eax, eax ; $B1$2: movapd xmml. XMMWORD PTR _a[eax] ;5.21 mulpd xmml, xmmO ;5.28 movapd XMMWORD PTR _a[eax]. xmml 18.14 add eax, 16 ;4.12 cmp eax, 800 ;4.12 jb $B1$2 ; Prob 99* ;4.12 Для того чтобы пример был компактным, режим развертывания циклов был отключен, к тому же в листинге показаны только те команды, которые реализуют цикл. Обратите внимание, что для удобства компиляторы C++ и Fortran производства Intel аннотируют каждую ассемблерную команду номером строки и столбца оригинальной инструкции исходного файла (; строка.столбец). Поэтому легко видеть, что SIMD-команды относятся к циклу в строках 4 и 5. Подсказки компилятору для векторизации В идеале должно было быть достаточно включить режим автоматической векторизации, а компилятор оттранслировал бы все программы в такую форму, которая наилучшим образом использует технологию SIMD. Хотя инженеры Intel и стремятся к достижению этой цели, в реальной жизни компилятору часто приходится помогать выявлять возможности для эффективной векторизации. В данном разделе представлены некоторые подсказки компилятору, которые служат решению этой задачи. Часто используемые подсказки компилятору В табл. 13.3 показан синтаксис подсказок компиляторам С и C++, уместных в контексте автоматической векторизации. В табл. 13.4 показан синтаксис этих же подсказок компилятору Fortran (исключая подсказку restrict, которая поддерживается только в С и C++).
Подсказки компилятору для векторизации 191 Таблица 13.3. Подсказки компилятору для векторизации (С и C++) Синтаксис подсказки на С и C++ #pragma ivdep #pragma vector always #pragma vector nontemporal #pragma vector [un]aligned #pragma novector #pragma loop count(int) #pragma distribute point restrict declspec(align(int,int)) assume_aligned(exp,int) Семантика He учитывать предполагаемые зависимости по данным Переопределить эвристику эффективности Включить потоковые сохранения Назначить [не]выровненные свойства Отключить векторизацию Оценить количество проходов Предложить точку для расщепления цикла Объявить исключительный доступ через указатель Требовать выравнивания памяти Объявить свойство выравнивания Таблица 13.4. Подсказки компилятору для векторизации (Fortran) Синтаксис подсказки на Fortran !DIR$ IVDEP !DIR$ VECTOR ALWAYS !DIR$ VECTOR NONTEMPORAL !DIR$ VECTOR [UNALIGNED !DIR$ NOVECTOR !DIR$ LOOP COUNT(INT) !DIR$ DISTRIBUTE POINT !DIR$ ATTRIBUTES ALIGN:INT::VAR !DIR$ ASSUME_ALIGNED EXP:INT Семантика He учитывать предполагаемые зависимости по данным Переопределить эвристику эффективности Включить потоковые сохранения Назначить [невыровненные свойства Отключить векторизацию Оценить количество проходов Предложить точку для расщепления цикла Требовать выравнивания памяти Объявить свойство выравнивания Большинство подсказок должно указываться непосредственно перед циклом, чтобы передавать компилятору определенную информацию о данном цикле. Например, вставка подсказки i vdep перед циклом означает, что компилятор может без всякой опасности не учитывать любые предполагаемые зависимости по данным, которые препятствуют векторизации цикла. Следующие фрагменты кода демонстрируют такое объявление в коде на языках С (слева) и Fortran (справа):
192 Глава 13 • Автоматическая векторизация #pragma ivdep !DIR$ IVDEP for (1=0; i<n-k; i++) { DO I = 1. N-K a[i] = a[i+k] - 1; A(I) - A(I+K) - 1 } ENDDO Путем использования подсказки (которая утверждает, что в цикле нет зависимостей по ходу выполнения между итерациями) программист может сообщить определенную информацию из предметной области, например, что значение переменной к всегда положительное. Подсказка не изменяет проверенных зависимостей по данным. Если в данном случае переменная к статически равна, например -1, то компилятор просто игнорирует подсказку i vdep, и цикл останется последовательным из-за уже доказанной зависимости по ходу выполнения. В ситуациях, когда векторизация цикла возможна, но встроенный в компилятор механизм эвристики эффективности считает векторизацию невыгодной (как показывают диагностические сообщения векторизации), вы можете использовать подсказку always для изменения такого решения. По этой подсказке векторизация цикла выполняется независимо от решения механизма эвристики эффективности. Однако эта подсказка не отменяет соображений корректности. Подсказка always в следующем фрагменте не имеет эффекта, поскольку компилятор просто не может выполнить векторизацию инструкции вывода в данном цикле: #pr agma vector always for (i = 0; i < 100; i++) { k « k + 10; a[i] - k; printf(«i=*d k=Sd\n», i, k); Наоборот, подсказка novector отключает векторизацию цикла, которая, например, кажется выгодной компилятору, но на деле замедляет выполнение программы. Подсказка nontemporal дает компилятору указание обрабатывать выровненные должным образом ссылки на память как потоковые данные (для минимизации загрязнения кэша). Программист может также использовать либо подсказку unal igned, либо подсказку al igned, дав компилятору указание предполагать, что все ссылки на память в цикле являются либо невыровненными, либо выровненными. Применение обеих подсказок требует осторожности. Неверное указание подсказки una! igned может привести к снижению производительности, а некорректное использование подсказки al igned может даже вызвать сбой программы. Если подсказки не даются, компилятор задействует усовершенствованные статические и динамические методы для анализа и даже принудительного обеспечения необходимого выравнивания ссылок на память. Таким образом, эти подсказки полезны только в том случае, если те варианты компиляторной оптимизации, которые предлагаются по умолчанию, не обеспечивают приемлемую производительность.
Подсказки компилятору для векторизации 193 Подсказка, касающаяся количества проходов, дает компилятору предполагаемое количество итераций цикла. Компилятор впоследствии использует эту информацию для определения, стоит ли выполнять векторизацию при компиляции под конкретные архитектуры. Таким образом, эта подсказка предлагает несколько более гибкий путь управления векторизацией, чем явное отключение или включение векторизации при помощи подсказки novector или always, поскольку окончательное решение о выгодности оптимизации принимает компилятор. Очевидно, что подсказка просто дает компилятору среднее количество проходов, при этом сгенерированный код работает правильно и при других значениях количества проходов. Вставка подсказок di stri bute poi nt внутрь цикла предлагает компилятору подходящие точки для расщепления цикла (без необходимости модификации кода). Вот пример: for (i = 0; i < N; i++) { for (i = 0; i < N; i++) { a[i] = 0; a[1] = 0; } #pragma distribute point предлагается -> for (i = 0; i < N; i++) { b[i] = 0; b[i] « 0; } } Ключевое слово restri ct, специфичное для С и C++, объявляет, что переменная- указатель обеспечивает эксклюзивный доступ ко всей связанной с ней памяти. Если, например, следующая функция add() применяется только к различным массивам, то информация об отсутствии совпадения имен у формальных аргументов — указателей р и q — может быть передана компилятору так: void add(char * restrict p. char * restrict q, int n) { int i; for (i =0; i < n; i++) { p[i] = q[i] + 1: } Даже без подсказки restrict данный пример автоматически векторизуется посредством динамического анализа зависимостей по данным, когда компилятор генерирует тест времени выполнения на перекрытие, который и позволяет сделать выбор между векторным и последовательным выполнением цикла. Однако добавление подсказки restrict дает компилятору возможность выполнить векторизацию цикла без издержек, вызванных такими тестами времени выполнения. Это ключевое слово позволяет декларировать свойства исключительного доступа переменным-указателям для многих случаев сразу, поскольку одной подсказкой restri ct вы избегаете утомительной вставки подсказки i vdep перед каждым потенциально векторным циклом, содержащим эти указатели. В отличие от подсказок, которые для других компиляторов выглядят как комментарии, это расширение языка требует флага -Qrestrict (Windows) или -restrict (Linux) для компиляции
194 Глава 13 • Автоматическая векторизация при помощи компилятора C++ производства Intel, в то же время в других компиляторах это может вызвать синтаксическую ошибку. В С или C++ добавление перед объявлением подсказки declspec(al ign- (база, смещение)), где 0 <= смещение < база = 2я, предлагает разместить объявленный элемент по адресу а, который удовлетворяет условию: a mod база = смещение Смещение не обязательно и по умолчанию устанавливается в нуль. Например, предположим, что большая часть времени выполнения программы проводится в цикле, показанном в следующем фрагменте кода: double a[N+l]. b[N]; for (i - 0; i < N; i++) { a[i+l] « b[i] * 3; } Поскольку компилятор, скорее всего, выберет 16-байтное выравнивание для обоих массивов, то после векторизации возникает либо невыровненная загрузка, либо невыровненная запись. Программист может предложить другое выравнивание (как показано далее), что в итоге позволит компилятору векторизовать важный цикл при помощи двух выровненных команд перемещения данных: _declspec(align(16. 8)) double a[N]; _declspec(align(16. 0)) double b[N]; /* или: align(16) */ В Fortran атрибут ALIGN обеспечивает аналогичную функциональную возможность, но без прямой поддержки дополнительного смещения. Наконец, подсказка assume_al i gnedi выражение, база) для база = 2" может быть использована в С, C++ и Fortran для объявления о том, что вся память, которая может быть связана с адресным выражением выражение, гарантированно удовлетворяет, по крайней мере, указанному выравниванию. Компилятор впоследствии задействует эту информацию при анализе выравнивания кода, который использует данное выражение. В своем простейшем виде выражение является простым формальным аргументом, указателем в С или параметром фиктивного массива в Fortran, как показано в следующем примере на С: void fill(int *x) { i nt i ; _assume_aligned(x, 16); for (i = 0; i < 1024; i++) x[i] = 1; } } В несколько более сложной форме добавляется постоянное смещение к переменной для определения нарушения выравнивания относительно базового адреса.
Подсказки компилятору для векторизации 195 Например, в следующем примере подсказка указывает, что указатель х связан только с памятью, где адрес а удовлетворяет условию a mod 64 = 4 (учитывая, что размер одного целого составляет 4 байт): void filKint *x) { int i; assume_aligned(x+3. 64); } Вы могли бы передать аналогичную подсказку для параметра фиктивного массива в Fortran: SUBROUTINE FILL(X) INTEGER X(*) !DIR$ ASSUME_ALIGNED X(3):64 END He используйте более сложных форм, чем показанные здесь, поскольку компилятор может выдать сбой при связывании сложных выражений с фактическими выражениями в ходе межпроцедурного и внутрипроцедурного анализа выравнивания. Обычно ориентированную на данные подсказку assume_al igned следует предпочитать ориентированной на цикл подсказке vector al igned тогда, когда от использования этой информации может выиграть множество циклов или когда только некоторые (но не все) ссылки на память в цикле выровнены. Как обычно, указывать любые подсказки нужно с осторожностью, поскольку ваша неточность в этом деле может привести к снижению производительности или даже сбою программы. Примеры подсказок компилятору Очень часто одну и ту же информацию можно передать компилятору разными способами. Например, в следующем коде показан один из способов, позволяющий сказать, что цикл может быть векторизован командами выровненного перемещения данных без необходимости динамического тестирования зависимостей по данным: doit(int *p, int *q, int n) { int i; Ipragma ivdep Ipragma vector aligned for (i =0; i < П; i++) { p[i] - q[i] t 1: } }
196 Глава 13 • Автоматическая векторизация Здесь программист использует подход, ориентированный на цикл, объявляя, что память, к которой происходит обращение в цикле, начинается с выровненного адреса и не перекрывается, по крайней мере так, чтобы это препятствовало векторизации. В следующей версии для передачи аналогичной информации компилятору программист выбирает подход, больше ориентированный на переменные: doitCint * restrict p. int * restrict q, int n) { int i; _assume_a 1 i gned (p, 16); _assume_al igned(q, 16); for (1*0; i < n; i++) { p[i] = q[i] * 1: } } Ориентированные на цикл подсказки полезны для быстрых экспериментов с различными способами векторизации одного цикла. Ориентированные на переменные подсказки полезны тогда, когда у вас есть конкретные знания о свойствах данных (к которым выполняется обращение), способные помочь компилятору лучше оптимизировать код, но при этом вы хотите оставить принятие всех решений о вариантах оптимизации компилятору Ориентированные на цикл подсказки иногда применимы и к двум другим типам программных конструкций. Во-первых, поскольку компиляторы производства Intel применяют автоматическую векторизацию также к линейному коду, который выполняет похожие операции над последовательными операндами в памяти (как показывает диагностическое сообщение BLOCK WAS VECTORIZED), то размещение подсказки непосредственно перед фрагментом может изменить способ его векторизации. Подсказка в следующем примере дает компилятору указание использовать потоковую команду сохранения для сброса массива: #pragma vector nontemporal а[0] = 0; а[1] = 0; а[2] = 0; а[3] = 0; Во-вторых, компиляторы Fortran производства Intel распознают также ориентированные на циклы подсказки, помещенные непосредственно перед простыми синтаксическими формами массивов в стиле F90. Пример подсказки, помещенной перед инструкцией, использующей тройные индексы: !DIR$ N0VECT0R А(1:100:1) = В(1:100:1) + С(1:100:1) В более сложных случаях вы можете иногда обнаружить, что компилятор не внимает таким подсказкам. Вы можете исправить ситуацию путем размещения подсказки перед эквивалентным фрагментом кода, в котором неявный цикл сделан явным.
Советы относительно векторизации 197 Советы относительно векторизации Размер большинства приложений делает ручную оптимизацию всех их частей невозможной. Поэтому для повышения производительности при помощи технологии SIMD практичнее использовать автоматическую векторизацию для программы в целом с последующим анализом производительности с целью определения мест, требующих дополнительной оптимизации. Эта последующая оптимизация может быть простым переписыванием исходного кода в форму, которая лучше поддается оптимизации, или (в качестве последнего средства) ручной векторизацией важных горячих точек с выбором одного из оставшихся вариантов оптимизации, описанных в главе 12 (библиотека классов C++, внутренние команды компилятора или подставляемый ассемблер). В данном разделе даются некоторые советы, которые могут помочь вам более эффективно использовать автоматическую векторизацию с компиляторами C++ и Fortran производства Intel. Соображения по разработке и реализации Решения, принимаемые на стадии разработки и реализации приложения, могут серьезно сказаться на итоговой производительности. При наличии выбора между несколькими алгоритмами с одинаковой вычислительной сложностью выбор того алгоритма, который лучше всех поддается векторизации, может в итоге привести к более высокой производительности. Для структур данных старайтесь выбирать организацию, выравнивание и ширину данных таким образом, чтобы при наиболее часто выполняемых вычислениях обращения к памяти могли выполняться благоприятным для технологии SIMD образом с максимально возможным параллелизмом (как уже рекомендовалось в главе 12). Для иллюстрации этой рекомендации рассмотрим проблему преобразования последовательности пар чисел в последовательность из сумм квадратов каждой пары. Простая реализация на языке С для восьми пар: double a[8]; double b[8][2] = { {1, 10}, {2, 20}, {3, 30}. {4, 40}. {5, 50}, {6, 60}. {7. 70}. {8. 80} }; void sumsquare(void) { int i; for (i = 0; i < 8; i++) { a[i] - b[i][0] * b[i][0] f b[i][l] * b[i][l]; } } Применение функции sumsquare() к содержимому массива Ь даст следующее содержимое массива а: { 101.0.404.0.909.0.1616.0.2525.0.3636.0.4949.0.6464.0 }
198 Глава 13 • Автоматическая векторизация Компилятор C++ производства Intel сообщает об успешной векторизации этой реализации, и вы обнаружите, что функция работает немного быстрее: => icl -Fa -QxP -с vl.c vl.c(8) : (col. 3) remark: LOOP WAS VECTORIZED. Однако изучение сгенерированного ассемблерного файла показывает, что перед вычислениями компилятор использует несколько команд реорганизации. Просмотрев исходный код этих команд, вы обнаружите, что реорганизация данных производится из-за несоответствия между эталоном доступа «по столбцам» в коде и порядком хранения массивов в С и C++ «по строкам». Для данной конкретной функции лучше было бы сохранить все первые элементы перед всеми вторыми элементами — такое преобразование структуры данных называется транспонированием матрицы 8 х 2 в матрицу 2x8. После соответствующего преобразования, кода внутри цикла изменяются только самые правые индексы массива, что дает благоприятные для SIMD ссылки на память с шагом в один элемент, то есть последовательные итерации цикла используют элементы данных, находящиеся в памяти по соседству: double a[8]: double b[2][8] - { { 1. 2. 3, 4, 5. 6. 8 }, {10. 20, 30, 40. 50. 60. 80 } }; void sumsquare(void) { i nt i ; for (i - 0; i < 8; i++) { a[i] - b[0][i] * b[0][i] ♦ b[l][i] * b[l][i]; } } Цикл снова векторизуется, но теперь вы обнаружите, что функция выполняется значительно быстрее (благодаря ссылкам с шагом в элемент данных). Вы можете «выжать» из этого цикла еще более высокую производительность, если одинарной точности для чисел с плавающей точкой будет достаточно для всех вычислений. Изменение типа данных с двойной точности на одинарную означает изменение двухканального SIMD-параллелизма на четырехканальный. Далее следует часть ассемблерного файла (с примечаниями), сгенерированного путем автоматической векторизации для окончательной версии: Movaps Movaps Movaps Movaps Mulps xmml. xmmO, xmm3, xmm2, xmml, XMMWORD PTR XMMWORD PTR XMMWORD PTR XMMWORD PTR xmml _b _b+32 _b+16 _b+48 1st 4 floats low 1st 4 floats high 2nd 4 floats low 2nd 4 floats high 1st 4 products low
Советы относительно векторизации 199 Mulps Mulps Mulps Addps Addps Movaps Movaps xmmO, xmmO xmm3. xmm3 xmm2, xmm2 xmml. xmmO xmm3, xmm2 XMMWORD PTR XMMWORD PTR Несмотря на то, что этот пример чрезвычайно упрощен, он все же иллюстрирует реальную важность выбора такой структуры данных, которая эффективно адаптирована к часто выполняющимся операциям. В некоторых случаях могут потребоваться гибридные структуры данных или даже преобразования между различными форматами на этапе выполнения, чтобы предоставить векторизующему компилятору наиболее благоприятные для использования технологии SIMD возможности. Обратите внимание, что поскольку язык Fortran хранит массивы по столбцам, для него благоприятные с точки зрения SIMD ссылки на память получаются тогда, когда внутри цикла изменяются только самые левые индексы массива. Использование диагностических сообщений при векторизации Диагностика векторизации полезна для определения тех мест, где автоматическая векторизация была успешной, и где она не удалась. В сочетании с информацией, полученной при помощи анализатора производительности, вы можете следующим образом использовать эту диагностику: □ переписать код или вставить подсказки компилятору, чтобы важные горячие точки (которые еще остались последовательными), лучше поддавались векторизации; □ отключить векторизацию тех фрагментов кода, в которых автоматическое преобразование в SIMD-команды отрицательно сказалось на производительности. В табл. 13.5 перечислено несколько обычных диагностических сообщений при векторизации для увеличивающихся значений п в параметре -Qvec-report<n> (Windows) или -vec-report<n> (Linux). Значение п = 1 дает информацию об успешной векторизации обычных циклов, распределенных циклов и прямолинейных фрагментов кода. Значение п = 2 в дополнение к этому позволяет получить информацию о циклах, векторизация которых не удалась, с кратким описанием причины неудачи. В таблице показано только несколько примеров. По мере увеличения возможностей компиляторов их более поздние версии, возможно, будут способны выдавать более подробные описания причин неудач, а возможно, и достигать успеха там, где предыдущие версии терпели неудачу. Например, сообщение vectori zati on possi bl e but seems i nef f i ci ent означает, что механизм эвристики эффективности считает векторизацию невыгодной, хотя компилятор в принципе способен генерировать векторный код. Как уже отмечалось, вы можете легко изменить это решение, вставив перед циклом подсказку #pragma vector always компилятору. is! 4 products nign 2nd 4 products low 2nd 4 products high 1st 4 sums 2nd 4 sums 1st 4 results 2nd 4 results
200 Глава 13 • Автоматическая векторизация Таблица 13.5. Обычная диагностика векторизации Значение Г и- 1,2,3 п = 2,3 п = 3 Диагностическое сообщение LOOP WAS VECTORIZED PARTIAL LOOP WAS VECTORIZED BLOCK WAS VECTORIZED loop was not vectorized: existence of vector dependence low trip count mixed data types not inner loop operator unsuited for vectorization subscript too complex statement cannot be vectorized unsupported loop structure vectorization possible but seems inefficient #pragma novector used vector dependence: proven [FLOW/ANTI/OUTPUT] dependence between... assumed [FLOW/ANTI/OUTPUT] dependence between... Однако не все неудачи векторизации так легко устранить. Зависимости по данным могут отражать ограничения очередности выполнения, которые полностью препятствуют векторизации. В таких случаях можно использовать значение п = 3 для получения информации о том, могут ли помочь задействовать векторизацию какие- нибудь описанные в литературе методы устранения зависимостей по данным [39,41]. На рис. 13.1 показано, как включить режим диагностики векторизации в среде разработки Microsoft Visual C++ .NET (просто путем добавления флага диагностики в командной строке). Не забудьте при этом включить сам режим векторизации, указав один из специфичных для конкретного процессора флагов (-QxP в данном примере). Как уже отмечалось, формат диагностических сообщений совместим с форматом среды разработки Microsoft Visual C++ .NET, где двойной щелчок на одном из диагностических сообщений в окне вывода перемещает фокус окна редактирования на соответствующую строку в файле исходного кода. Эта возможность полезна для того, чтобы быстро определить, какие циклы векторизованы, а какие нет. На рис. 13.2 показано, как переместить фокус на цикл, который не был векторизован из-за доказанной зависимости по ходу выполнения.
Советы относительно векторизации 201 шшшт s»H0 Md Debug look №dow t**> f X Уе*+оглр*|чи^л>! ! r.«ad«a Ut ■ rtdarV.w ! hurf.qjp ! IStf 38*39- P Header fites JQ ReadMe.:xt ReadV :(«ЭоЬаЬ) ~3 f*sr J":l#include "stdafx.h" double a[100], b[100); void doit(void) { 11 int i; В for (i=l;i<100;i++) { a[i] = a[i-l] * 7.0 Ш Configuration: |Actrve(Rele«e) I Ptatform: |Artrve(Wln32) Cofflguratton Manager... General Debugging a oc++ General CjJ Linker fl| Browse Information _JBuM Events _| Custom Buld Step /c /03 /Og /0b2 /Ot /GA /D "WIN32" (D "NDEBUG" /D " CONSOtT /D "_MBCS" fFO /EHsc /M /Yu"StdAfx.h" /Fp"Release/Vector.pch" /Po-Releasef /W3 /nologo /WpM /Gd -Qvec_r«port3 /QxN /Qmufcbyte^fws J f x Рис. 13.1. Включение режима диагностики векторизации в Microsoft Visual C++ .NET 2003 Далее следует пример того, как диагностика векторизации может помочь разрешить часто возникающее осложнение со структурой цикла. Рассмотрим файл исходного кода surprize.c со следующим содержимым: int bnd « 100; void reset(int *p) { int i; for (i = 0; i < bnd; i++) p[1]-0: Несмотря на то, что цикл выполняет только простую инициализацию области памяти, автоматическая векторизация заканчивается неудачей. Для того чтобы определить причину, необходимо просто запросить диагностику векторизации следующим образом:
202 Глава 13 • Автоматическая векторизация тттшттттшттжттшш Е*в Е* y>»w P/cJect Quid Qebug look Mndow Цв|р }■#.!" 'Ш'вР ■ Ш \ jt % e ] "l* rv - Д) . 54 : > Rete^e Solution Explorer - Vector »_*jj Vector-cop | > h «n.-Me.!rf |я* q Q ,[(<***) - ..*» —.:f.«. : * 1 щ Ш. я *Jt-. J ! ♦** HHHHHSB ; W| 13 —fl SJrtdafe.h Q3 Resource Fie* JQ ReadMe.rxt ;• p#include "stdafx.h" ; j double a[100], b[100]; [I •"-'Jvoid doit (void) { П int i; [ьйН for (i=l;i<100;i++) { a[i] = a[i-l] * 7.0; h > Г| В for (i=0;i<100;i++) { b[i] = sin( a[i] ); ) N Output l« ; itdeix.cpp ;" Compiling wi Vector.cpp h Intel(R) CM ».0 . .(Intel C++ Environment) , A ! * * 3 " 1 ШШШШШШШЩ . Wector. cpp(8) : (col. 2) remark loop ves not vectorized: . Wector.cpp(ll) : (col. 2> remmrr: LOO» «AS VICTORIZIP. Linking. .. (Intel C++ Invironment) xllink: executing 'link' Рис. 13.2. Использование диагностики при векторизации в Microsoft Visual C++ .NET 2003 => id -c -QxP -Qvec-report2 surprise.с surprise.c(7) : (col. 3) remark: loop was not vectorized: unsupported loop structure. Это диагностическое сообщение означает, что компилятор не может определить, является ли цикл «хорошим», то есть имеет ли он простые условия входа и выхода. Причина в том, что компилятор обязан педантично предполагать, что снятие косвенности указателя p[i ] может модифицировать значение переменной bnd. Сначала это может показаться нелепым, но компиляторы всегда должны рассчитывать на самый худший случай, в том числе и на возможность, что данная функция может быть вызвана как reset (&bnd). Подобные проблемы можно разрешить несколькими способами. Например, простое объявление глобальной переменной как static или увеличение области видимости компиляции при помощи флага оптимизации нескольких файлов (-Qi po в Windows или -i po в Linux) может дать компилятору достаточно информации, чтобы понять, что адрес переменной
Советы относительно векторизации 203 никогда не будет использоваться как фактический аргумент функции. Либо вы можете помочь компилятору обнаружить «хороший» цикл следующим образом (при условии, что вы не собираетесь применять данную функцию с описанным ранее вариантом совпадения имен): void reset(int *p) { int i; int locBnd = bnd; for (i = 0; i < locBnd; i++) p[i] = 0; } Такая модификация исходного кода делает векторизацию цикла возможной: => 1с! -с -QxP -Qvec-report2 surprise.с surprise.c(8) : (col. 3) remark: LOOP WAS VECTORIZED. Минимизация возможных эффектов совпадения имен и побочных эффектов векторизации В качестве общей рекомендации по снижению эффекта от предполагаемого компилятором совпадения имен и побочных эффектов увеличивайте область видимости компиляции настолько, насколько позволяют ограничения этапа компиляции, и используйте спецификаторы типа static и restrict там, где это возможно. Цикл в исходной версии функции reset (), обсуждавшейся в предыдущем разделе, векторизуется также и при помощи ключевого слова restrict, примененного к формальному аргументу-указателю р. Для программ, которые удовлетворяют стандартам ANSI, использование флага -Qansi -alias (Windows) или -ansi -al i as (Linux) может еще больше сократить количество предполагаемых осложнений, касающихся совпадения имен. Обратите внимание, что спецификатор const не так полезен в плане избежания потенциального совпадения имен, как первоначально предполагалось. Рассмотрим, например, следующую функцию ptr(), где формальные аргументы a, b и С объявлены как неограниченный указатель на целые, указатель на постоянные целые и постоянный указатель на целые соответственно: static void ptrdnt *a, const int *b, int *const c) { } Тогда внутри данной функции компилятор принимает инструкции такого типа: b = NULL; /* указатель может изменяться */ с[0] = 1; /* элементы могут изменяться */
204 Глава 13 • Автоматическая векторизация Однако отвергает следующие инструкции: Ь[0] - 1; /* неправильно: элементы не могут меняться */ с - NULL; /* неправильно: указатель не может меняться */ Тем не менее, при компиляции без последующего контекста компилятор все равно выполняет динамическое тестирование зависимостей по данным для цикла, наподобие такого: for (i = 0; i < 16: i++) { a[i] = b[i]; } Проблема в том, что хотя стандарты языков С и C++ не разрешают присваивание адреса чего-то постоянного непосредственно неограниченному указателю, можно присвоить адрес чего-то переменного указателю на константу, поскольку вреда от этого не будет, как сказал классик [31]. К сожалению, это подразумевает, что функция pt г () может быть также вызвана так, как показано в следующем фрагменте, что приводит к прямой зависимости по ходу выполнения между итерациями цикла: int x[17]; ptr(x+l, х, ...)'; Правильным подходом, позволяющим избежать издержек динамического тестирования зависимостей по данным (предполагая, что такого совпадения имен для данной функции никогда не случится), является применение ключевого слова restri ct к формальным аргументам-указателям или достаточное расширение области видимости компиляции. Спецификатор const все же может исключить некоторые возможности для совпадения имен, будучи примененным непосредственно к объявляемому объекту Цикл в следующей функции может быть векторизован без динамического тестирования зависимостей по данным, поскольку теперь компилятор может без всякой опасности сделать заключение, что содержимое массива b никогда не сможет быть изменено через указатель а: static const int b[] = { 1.2,3,4,... }; void copyb(int *a) { int i; for (i = 0: i < 100: i++) { atl] = b[i]; } } Цикл, который содержит вызовы функций, должен оставаться последовательным, если только функции не подставлены компилятором, а полученный код не поддается векторизации, или если функции не поддерживаются библиотекой
Советы относительно векторизации 205 SVML (Short Vector Mathematical Library)1, предлагающей эффективные SIMD- реализации для широкого спектра тригонометрических, гиперболических, экспоненциальных, логарифмических и нескольких других типов функций. Поскольку все векторные аргументы и результаты передаются через SIMD-регистры, эта библиотека позволяет компилятору векторизовать вызовы поддерживаемых функций, как если бы они были простыми инструкциями. Иногда сочетание технологий подстановки функций и распознавания функций, поддерживаемых SVML, позволяет выполнить векторизацию очень сложных и расходующих массу времени частей научных и инженерных приложений. Эта ситуация иллюстрируется следующим примером на языке Fortran (любезно предоставленным компанией Scandpower Petroleum Technology): DOUBLE PRECISION FUNCTION PIFF(B) DOUBLE PRECISION В, Е, F E = MIN(MAX(B.0.0D0).1.0D0) F = 1 - E PIFF = 1.6765391932197435D00*(1+E**(1.0D0/3.0D0)- & 0.12614422239872725D00*E-**(1.0D0/3.0D0))& & - 0.005D00*(4*(F*F+E*E)+1)*F*E*(1-2*E) END FUNCTION PIFF Когда эта функция вызывается в цикле (как показано далее), подстановка функции целиком выявляет различные математические манипуляции, которые поддерживаются либо непосредственно в SSE2, либо при помощи SVML: DO I = 1. N P(I) = PIFF(Bd)) SINNS(I) - SIN(P(D) COSNS(I) - C0S(P(D) ENDDO Следовательно, несмотря на наличие вызовов функций и прочие математические манипуляции, компиляторы производства Intel все же автоматически векторизуют циклы такого вида. То есть, хотя вызовы функций и способны в итоге исключить векторизацию, не начинайте немедленно ликвидировать все функции в вашей программе. Сначала ознакомьтесь с возможностями компилятора, связанными с подстановкой и векторизацией, и переписывайте код только там, где это совершенно необходимо. Таким образом, ваш код не только останется понятным и несложным для поддержки, но и в итоге будет иметь удовлетворительную производительность. Наконец, помните о побочных эффектах, когда вследствие исключения происходит выход из цикла. Рассмотрим простой пример следующего цикла в файле исходного кода assert.c, где объявляется инкрементированный указатель, не превосходящий определенного адреса: 1 Разработана в лаборатории Intel в Нижнем Новгороде, Россия.
206 Глава 13 • Автоматическая векторизация #include <assert.h> while (cnt-- > 0) { assert(p < top); /* строка 8 */ *p++ = 0; } При попытке векторизовать этот цикл с параметрами компилятора, принятыми по умолчанию, появляется следующее сообщение, информирующее программиста о том, что цикл содержит инструкцию, которая препятствует векторизации: => icl -QxP assert.с assert.c(7) ; (col. 3) remark: loop was not vectorized: contains unvectorizable statement at line 8. Виновато здесь неявное условие выхода их функции assert (). При компиляции с подавлением объявлений цикл векторизуется: -> icl -QxP -DNDEBUG assert.с assert.c(7) : (col. 3) remark: LOOP WAS VECTORIZED. Если все эти подходы не дают успеха, попробуйте переписать код так, чтобы явно устранить предполагаемое совпадение имен и побочные эффекты (как было проиллюстрировано ранее введением локальной переменной для определения границ цикла). Очень часто диагностическое сообщение о неудаче является прямым следствием несоблюдения стиля программирования с расчетом на векторизацию, о чем рассказывается в следующем разделе. Стиль программирования Вообще говоря, применение ясного стиля программирования (который минимизирует возможные эффекты совпадения имен и побочные эффекты) дает код, который максимально поддается автоматической векторизации. В этом контексте рекомендации таковы: используйте простые структуры циклов и границы циклов; простые индексные выражения, а не сложную адресную арифметику с разнообразными указателями, избегайте ручного развертывания циклов. Когда прежние компиляторы не оптимизировали циклы так хорошо, как этого хотелось бы программистам, популярными стали некоторые сложные методики программирования. Однако в наши дни большинство оптимизирующих компиляторов гораздо лучше справляется с повышением производительности циклов. Например, старые компиляторы не всегда шли на упрощение команд в адресной арифметике, являющейся результатом обработки индексных выражений, вследствие чего многие программисты предпочитали использовать вместо этого несколько усовершенствованных указателей. Современные компиляторы без проблем справляются с таким
Советы относительно векторизации 207 упрощением команд, при условии, что программист максимально соблюдает указания по минимизации возможных эффектов совпадения имен и побочных эффектов. При экспериментах с компиляторами производства Intel вы скоро поймете, какой благоприятный для векторизации стиль вам подходит больше всего. Следование описанным стилям программирования часто также способствует понятности и удобству сопровождения кода, хотя здесь играют роль и субъективные факторы вроде персональных вкусов. Например, у разных программистов будут разные мнения относительно того, какой из следующих аналогичных по возможностям циклов элегантнее, но вы обнаружите, что когда циклы становятся более сложными, то стиль правого лучше подходит для автоматической векторизации. К счастью, в данном случае автоматически векторизуются оба цикла: while (--n) { for (i=0;i<n;i++) { *р++ - *q++: p[i] = q[i]: } } Однако несмотря на продолжающуюся работу по совершенствованию компиляторов, иногда единственным вариантом применения технологии SIMD является оптимизация фрагмента кода вручную при помощи библиотек классов C++, внутренних команд компилятора или подставляемого ассемблера. Даже если вам придется к этому прибегнуть, можете сохранить оригинальный исходный код при помощи такого макроса: #ifdef _HAND0PT /* оптимизированная вручную реализация */ _asm { } #else /* исходный код */ #endif Поскольку оригинальный исходный код обычно яснее отражает функциональность фрагмента, макрос упрощает поддержку и дает вам возможность иногда сравнивать качество оптимизированной вручную реализации с кодом, который генерируется разными (или недавно появившимися) компиляторами. По умолчанию компиляторы задействуют оригинальный исходный код. Оптимизированную вручную реализацию можно будет включить в генерируемый двоичный файл, дополнительно указав флаг -D HAND0PT компилятора (где и задается макрос). Целевые архитектуры Компиляторы C++ и Fortran производства Intel используют одну и ту же методику векторизации для всех целевых архитектур [6], имеющих мультимедийные
208 Глава 13 • Автоматическая векторизация расширения, начиная от технологий ММХ и потоковых SIMD-расширений для процессоров IA-32 и ЕМ64Т [18] и заканчивая технологией Intel Wireless MMX для микроархитектуры Intel Xscale® [26], а некоторые мультимедийные команды поддерживаются даже процессорами семейства Itanium [37]. Поэтому программисты, которые переходят от одной из этих архитектур к другой, попадают в привычные для себя условия автоматической векторизации. Кроме того, большинство рекомендаций, которые даются в этой главе, применимы к любой из этих целевых архитектур. Однако при анализе сгенерированных SIMD-команд знание целевой архитектуры может быть весьма полезным. Рассмотрим, например, следующий цикл, где все массивы имеют тип короткого целого: for И - 0; i < 100; 1-м-) { a[i] = х[0] * х[0] * bO[i] ♦ х[1] * х[1] * bl[i] ft х[2] * х[2] * Ь2[1] + х[3] * х[3] * b3[i] + х[4] * х[4] * Ь4[1] + х[5] * х[5] * Ь5[1] + х[6] * х[6] * Ь6[1] + х[7] * х[7] * Ь7[1] ; } Программист, применяющий автоматическую векторизацию для архитектуры IA-32 или ЕМ64Т, не увидит никакой разницы в выдаваемых при векторизации диагностических сообщениях. В обоих случаях компилятор просто информирует о векторизации цикла: xxbmul.c(15) : (col. 3) remark: LOOP WAS VECTORIZED. Однако при проверке сгенерированных ассемблерных команд выявится очевидная разница. Для процессора Pentium 4 будет сгенерирован следующий код: $В1$2: movdqa xmmO. XMMWORD PTR _Ь0[еах] pmullw xmmO, xmm6 movdqa xmm7, XMMWORD PTR _bl[eax] pmullw xmm7, xmm5 paddw xmmO, xmm7 movdqa xmm7, XMMWORD PTR _b2[eax] pmullw xmm7, xmm4 paddw xmmO, xmm7 movdqa xmm7. XMMWORD PTR _b3[eax] pmullw xmm7, xmm3 paddw xmmO, xmm7
Советы относительно векторизации 209 movdqa xmm7, XMMWORD PTR _Ь4[еах] pmullw xmm7. xmm2 paddw xmmO, xmm7 movdqa xmm7, XMMWORD PTR _b5[eax] pmullw xmm7, xmml paddw xmmO, xmm7 movdqa xmm7, XMMWORD PTR _b6[eax] pmullw xmm7, XMMWORD PTR [esp+48] ; стековый операнд paddw xmmO, xmm7 movdqa xmm7, XMMWORD PTR _b7[eax] pmullw xmm7, XMMWORD PTR [esp+32] ; стековый операнд paddw xmmO, xmm7 movdqa XMMWORD PTR _a[eax]. xmmO add eax, 16 cmp eax, 192 jb $B1$2 Здесь компилятор вынес из векторного цикла восемь инвариантных относительно цикла вычислений, начиная сх[0]хх[0]идох[7]хх[7]. Однако набор 128-разрядных регистров архитектуры IA-32 не имеет достаточного количества регистров для того, чтобы предварительно загрузить все эти вычисления в регистры, из чего следует, что некоторые операнды должны быть взяты из стека. В отличие от этого, при векторизации для архитектуры ЕМ64Т будет получен следующий код: $В1$2: movdqa xmm8, XMMWORD PTR bO[rlO+rll] pmullw xmm8, xmm7 movdqa xmm9, XMMWORD PTR bl[rlO+rll] pmullw xmm9, xmm6 paddw xmm8, xmm9 movdqa xmm9, XMMWORD PTR b2[rlO+rll] pmullw xmm9, xmm5 paddw xmm8, xmm9 movdqa xmm9, XMMWORD PTR Ь3[г10+П1] pmullw xmm9, xmm4 paddw xmm8, xmm9 movdqa xmm9, XMMWORD PTR b4[rlO+rll] pmullw xmm9, xmm3 paddw xmm8, xmm9 movdqa xmm9, XMMWORD PTR b5[rlO+rll] pmullw xmm9, xmm2
210 Глава 13 • Автоматическая векторизация paddw xmm8. xmm9 movdqa xmm9, XMMWORD PTR Ьб[г10+г11] pmullw xmm9, xmml paddw xmm8, xmm9 movdqa xmm9, XMMWORD PTR Ь7[г10+г11] pmullw xmm9, xmmO paddw xmm8, xmm9 movdqa XMMWORD PTR a[rlO+rll]. xmm8 add rll, 16 cmp rll, 192 jl $B1$2 Данная целевая архитектура поддерживает достаточное количество 128-раз рядных регистров для предварительной загрузки всех инвариантных вычислени в регистры. Иногда векторизация заканчивается неудачей только потому, что целев: архитектура не поддерживает необходимые SIMD-команды (даже когда во все остальных отношениях векторизация допустима). Рассмотрим, например, тако файл mul32.c: int a[16], b[16]; void mul(void) { int i; for (i = 0; i < 16; i+t) { a[i] *- b[i]; } } При попытке выполнить автоматическую векторизацию для процессор Pentium 4 этот цикл отвергается просто потому, что потоковые SIMD-расширен ~ не имеют прямой поддержки упакованного умножения двойных слов, как следу из диагностического сообщения: => icl -QxP -Qvec-report2 mul32.c mul32.c(5) : (col. 5) remark: loop was not vectorized: operator unsuited for vectorization. Основные моменты Для того чтобы получить максимум выгод от автоматической векторизации выполняемой компиляторами C++ и Fortran производства Intel, помните еле дующее:
Основные моменты 211 □ Ознакомьтесь со всеми параметрами компилятора и подсказками, имеющими отношение к автоматической векторизации. □ При возможности выбора между несколькими алгоритмами с одинаковой вычислительной сложностью, выбирайте тот, который лучше всех поддается векторизации. □ Создавайте свои структуры данных с такими организацией, выравниванием и шириной данных, чтобы самые часто выполняемые вычисления могли обращаться к памяти благоприятным для технологии SIMD образом с максимальной степенью параллелизма. □ При векторизации совместно используйте диагностику и анализ производительности с целью определения тех мест, в которых код, оставшийся последовательным, желательно сделать поддающимся автоматической векторизации, а также тех, в которых необходимо отключить автоматическую векторизацию, поскольку она отрицательно влияет на производительность. □ Используйте ясный стиль программирования, который минимизирует потенциальные эффекты совпадения имен и побочные эффекты векторизации. □ Даже тогда, когда вы прибегаете к ручной оптимизации фрагмента кода, можно сохранить оригинальный исходный код с помощью макроконструкции вида #i fdef-#el se-#endi f, что позволяет упростить сопровождение кода и сделать возможными в будущем эксперименты с различными компиляторами или их новыми версиями. □ Автоматическая векторизация выглядит и работает одинаково для всех поддерживаемых целевых архитектур. Однако знание целевой архитектуры помогает понять различия в векторизованном коде.
Специфические для процессоров варианты оптимизации Концепции оптимизации, обсуждаемые в этой книге, пригодны для всех процессоров архитектуры IA-32 производства Intel, однако некоторые варианты оптимизации требуют знания конкретных функциональных возможностей и архитектуры кэша конкретного процессора. Для решения некоторых проблем нужно знание языка ассемблера, в то время как другие могут быть решены при помощи языка высокого уровня. В данной главе сравниваются две основных архитектуры Intel — IA-32 и ЕМ64Т, и даются советы по оптимизации каждой из них. 32-разрядные архитектуры Intel Архитектура IA-32 от Intel началась с микропроцессора Intel386 в 1985 году. Оптимизация конкретно под процессор Intel386 состояла в тщательном подборе лучших ассемблерных команд и их эффективном упорядочивании. Это была утомительная и трудоемкая работа, поэтому только самые требовательные приложения кодировались вручную на ассемблере. Несколькими годами позже был выпущен процессор Intel486, который тоже зависел от языка ассемблера. Но в 1993 году стали доступными реальные варианты оптимизации для Pentium. Путем написания алгоритма, следующего определенному набору правил соответствия, производительность можно было в некоторых случаях удвоить. Появились компиляторы и инструменты оптимизации, помогающие в оптимизации и анализе последовательности команд (с целью достижения максимальной производительности). Новые процессоры и инструменты сместили фокус с очередности выполнения команд к высокоуровневым концепциям, таким как организация данных для эффективного доступа к памяти, использование SIMD-команд, многопоточность и сокращение зависимостей по данным.
32-разрядные архитектуры Intel 213 Специфичные для процессорных архитектур варианты оптимизации подразделяются на следующие категории: р Параллельная обработка с использованием потоков. □ Векторизация для повышения степени параллелизма на уровне команд благодаря использованию ММХ-команд и потоковых SIMD-расширений (SSE, SSE2 и SSE3). □ Доработка кода под конкретные для данного процессора функциональные возможности кэшей уровней 1 и 2 и автоматическую предвыборку. □ Использование самых быстрых последовательностей команд с одновременным отказом от медленных команд. Например, добавление к содержимому регистра единицы командой add на процессоре Pentium 4 происходит быстрее, чем командой инкремента. □ Использование инструментов и компиляторов, которые помогают выявлять архитектурные проблемы и генерируют код, оптимизированный для конкретной группы (или групп) процессоров. В табл. 14.1 представлены основные архитектуры Intel и высокоуровневые стратегии оптимизации. Таблица 14.1. Обзор процессорных архитектур IA-32 от Intel Архитектура Ранняя 32-разрядная Семейство процессоров Pentium Процессор Intel386, Intel486 Процессоры Pentium и Pentium с поддержкой технологии ММХ Основные отличия Первые процессоры с 32-разрядными регистрами и улучшенной адресуемостью памяти Кэши уровней 1 и 2, двойной конвейер (две команды одновременно) Микроархи- Процессоры тектура Р6 Pentium Pro, Pentium, Pentium III, Pentium M Суперскалярная архитектура — команды декодируются в микрооперации. За такт могут выполняться три микрооперации. Введение внеочередного выполнения команд, глубокий конвейер со многими ступенями, Стратегия оптимизации Тонкости, реализуемые на ассемблере Реализуемое на компьютерном языке или на языке ассемблера соответствие U-V, гарантирующее, что в каждом такте выполняются две команды. Использование команд новой технологии ММХ Использование дополнительных возможностей процессора. Организация данных для кэшей уровней 1 и 2; организация кода под эталон декодирования 4:1:1. Использование новых SIMD-команд для чисел с плавающей точкой одинарной точности, отход от медленных команд типа продолжение &
214 Глава 14 • Специфические для процессоров варианты оптимизации Таблица 14.1. (продолжение) Архитектура Микроархитектура NetBurst Технология гиперпоточности Технология ЕМ64Т (Extended Memory 64 Technology) Двухъядер- ная технология Процессор Процессор Pentium 4 Процессор Pentium 4 Процессор Pentium 4 Процессоры Pentium D и Pentium Extreme Основные отличия алгоритм предсказания переходов, спекулятивное выполнение, команды-подсказки кэша, дополнительные SIMD-команды Исполнительные устройства двойной скорости могут выполнять команды за полтакта. Кэш трасс заменил кэш команд; введены дополнительные SIMD- команды Один процессор может одновременно выполнять задания от имени нескольких программных потоков Расширение набора команд архитектуры IA-32 для 64-разрядной адресации и введение 64-разрядных регистров Два исполнительных в одном корпусе Стратегия оптимизации изменения управляющего слова, операций с плавающей точкой и частичных остановов регистров. Сокращение зависимостей по данным и неверных предсказаний переходов. Применение оптимизирующих компиляторов, которые делают все вышеперечисленное Использование SIMD- команд, в том числе новых команд двойной точности с плавающей точкой и дополнительных целых команд, а также автоматической аппаратной предвыборки. Сокращение зависимостей по данным и неверных предсказаний переходов. Применение оптимизирующих компиляторов Использование многопоточных приложений. Применение автоматических поточных компиляторов или технологии ОрепМР Гораздо большее виртуальное адресное пространство, более крупные регистровые файлы и арифметические операции. Использование оптимизирующих компиляторов, которые поддерживают расширения ЕМ64Т- команд Использование многопоточных приложений, автоматических поточных компиляторов или технологии ОрепМР
Процессор Pentium M 215 При разработке приложения сосредоточьте ваши усилия по оптимизации, направленной на поддержку самых новых процессоров. Во-первых, тот, кому нужны чувствительные к производительности приложения, почти наверняка имеет новый компьютер. Во-вторых, тот, кто покупает новый компьютер, покупает также и большую часть нового программного обеспечения. С учетом этого данная глава посвящена деталям, касающимся процессоров Pentium M, Pentium 4 и Pentium D. Процессор Pentium M На рис. 14.1 приведена блок-схема основных функциональных блоков процессора Pentium M. Системная шина Кэш уровня 2 Модуль интерфейса шины Кэш команд уровня 1 Кэш данных уровня 1 Выборка и декодирование Выполнение ПортО Порт 4 Порт1 Порт 2 ПортЗ Удаление Пул команд Рис. 14.1. Блок-схема функциональных блоков процессора Pentium M (Команды выполняются на процессорах Pentium 4 и Pentium M с использованием схожих процессов (с несколькими отличиями, которые объясняются позднее в данной главе). Каждое ядро процессора Pentium D выполняет команды практически аналогично процессору Pentium 4 и имеет свои индивидуальные кэши (уровней 1 и 2, но ядра совместно используют шину памяти процессора.
216 Глава 14 • Специфические для процессоров варианты оптимизации Кэш команд уровня 1 На процессоре Pentium M кэш уровня 1 разбит на две 32-килобайтные области — одна для команд, другая для данных (всего 64 Кбайт). Кэш команд уровня 1 извлекает команды из памяти и поставляет их в декодеры для преобразования в микрооперации. Поскольку команды могут иметь длину в несколько байтов или более, возможно, что команда начинается в одной строке кэша, а заканчивается в другой. Когда это происходит, процессор должен ждать выборки обеих строк кэша из памяти, чтобы можно было начать декодирование команды. Потенциальная потеря времени приблизительно равна времени выборки одной строки кэша, что может равняться примерно сотне тактов. Обычно разделенные команды не представляют собой проблемы, поскольку процессор находится в стабильном процессе выборки, декодирования, выполнения и удаления команд, поэтому задержка выборки второй строки кэша редко становится узким местом. Однако в некоторых случаях при выполнении короткой функции, которая является целью перехода и требует высокой производительности, может быть выгодно избежать этого ожидания за счет выравнивания целей перехода по границам строк кэша. Эта проблема в основном затрагивает процедуры обслуживания прерываний, драйверы устройств и другие короткие функции, которые не находятся в кэше уровня 1. Процессоры Pentium 4 и Pentium D выполняют команды из кэша трасс так, как это объяснено в главе 5. Декодирование команд Процессор Pentium M использует три блока декодирования, чтобы разбить команды архитектуры IA-32 на микрооперации. Один из блоков может обрабатывать все типы команд, а два оставшихся — только простые команды, которые преобразуются ровно в одну микрооперацию (рис. 14.2). Декодер 0. Может декодировать все команды Декодер 1. Только 1 микрооперация Декодер 2. Только 1 микрооперация Рис. 14.2. Три декодера в процессоре Pentium M На каждом такте процессор пытается декодировать три команды. Но это возможно только в том случае, если очередность поступления команд соответствует возможностям блоков декодирования. Поэтому когда последовательность команд содержит команды, которые декодируются в две, одну и одну микрооперации, то все команды декодируются за один такт, в то же время последовательность команд, которая декодируется в одну, одну и две микрооперации, декодируется за два такта. Можно повысить эффективность декодирования команд, организовав команды в последовательность 4:1:1. Для первого декодера выбрано значение 4, поскольку
Латентность команд 217 декодирование команд в 5 и более микроопераций может выполняться только по одной. Однако в отличие от процессора Pentium, где соответствие U-V повышало производительность, последовательность 4:1:1 гарантирует не общий прирост производительности, а только более эффективное декодирование команд. Самым лучшим и простым способом добиться эффективного декодирования команд является использование оптимизирующего компилятора, который способен планировать выполнение кода для обоих процессоров — Pentium M и Pentium 4. Анализатор производительности VTune™ может обнаружить проблемы декодирования команд на процессоре Pentium M, сравнивая показатели счетчиков Instructions Decoded и Clockticks. Но не тратьте слишком много времени на анализ проблем декодирования команд, поскольку сокращение зависимостей по данным гораздо важнее. Сокращение зависимостей по данным путем разнесения зависимых от данных команд как можно дальше помогает всем процессорам, так как дает им возможность выполнить больше команд за такт, что лучше сказывается на производительности, чем декодирование команд. Латентность команд Процессоры Pentium M, Pentium 4 и Pentium D выполняют команды за разное время. В табл. 14.2 приведен список значений латентности и пропускной способности самых часто используемых команд. Таблица 14.2. Латентность и пропускная способность команд (латентность/пропускная способность) Команда Целочисленные команды блока ALU (сложение, вычитание, поразрядные операции OR, AND, и т. д.) Целочисленные сдвиги/циклические сдвиги Целочисленное умножение Умножение с плавающей точкой Деление с плавающей точкой: — одинарной точности — двойной точности — расширенной точности Технология ММХ (ALU) . Процессор Pentium M 1/1 1/1 4/1 5/2 18/18 32/32 38/38 1/1 Процессор Pentium 4 0,5/0,5 4/1 14-18/3-5 7/2 23/23 38/38 43/43 2/1 Процессор Pentium D 1/0,5 1/0,5 10/1 8/2 30/30 40/40 44/44 2/1 продолжение &
218 Глава 14 • Специфические для процессоров варианты оптимизации Таблица 14.2. (продолжение) Команда Технология ММХ (умножение) Большинство SSE-команд SSE-деление: — скалярное — упакованное Большинство 55Е2-команд 55Е2-деление: — скалярное — упакованное Процессор Pentium M 3/1 3-5/1-3 18/18 36/36 3-6/1-3 32/31 63/62 Процессор Pentium 4 8/1 4-6/2 22/22 32/32 2-8/2-4 38/38 69/69 Процессор Pentium D 9/1 4-6/2 32/32 40/40 2-6/2 39/39 70/70 Самая большая разница между процессорами состоит в том, что процессоры Pentium 4 и Pentium D могут выполнять в два раза больше целочисленных ALU- команд за один такт, чем процессор Pentium M. Но в целом процессоры очень похожи, поэтому повторим еще раз: уделять повышенное внимание сокращению зависимостей по данным проще, и это идет на пользу всем процессорам. Набор команд При появлении новых процессоров часто появляются и новые команды. Например, технология ММХ, три поколения потоковых SIMD-расширений (SSE, SSE2, SSE3), команды управления кэшем, 64-разрядные ЕМ64Т-расширения Intel — все эти технологии (и соответствующие команды) появились после процессора Pentium. Использование новых команд (в подходящих случаях) обычно открывает новые возможности по оптимизации и повышению производительности. Важно изучить документацию на процессор и компилятор, чтобы ознакомиться с новыми функциональными возможностями и продумать, где их можно использовать в вашем приложении. Вы можете найти подробности по дополнительным командам, которые реализованы в процессоре, в томе 1 руководства [18]. Используйте новые команды разумно, удостоверяясь, что они не будут выполняться на старых процессорах. Если новая команда выполняется на старом процессоре, то происходит сбой по недопустимой команде, и приложение аварийно завершается. Требуемый процессор может быть установлен при помощи команды CPU ID или параметра компилятора C++ от Intel, относящегося к выбору процессора (детали см. в главе 3).
Кэш данных уровня 1 219 Управляющий регистр для операций с плавающей точкой Обычной причиной потери производительности является изменение управляющего регистра для операций с плавающей точкой либо с целью выставления режима округления при преобразовании из числа с плавающей точкой в целое, либо для реализации математических процедур floor и cei 1 (детали см. в главе 11). Команда, которая загружает управляющий регистр для операций с плавающей точкой (ассемблерная команда FLDCW), вызывает сериализацию на процессоре Pentium III, что часто приводит к значительной потере производительности. На процессорах Pentium 4, Pentium D и Pentium M механизм изменения режима округления в управляющем регистре был значительно улучшен, однако сделано это было не бесплатно. А использование более чем двух разных режимов округления вызывает потерю производительности на всех процессорах. Поэтому не следует часто изменять управляющее слово для операций с плавающей точкой. Вместо этого следует задействовать SSE- или 55Е2-команды для реализации как преобразования из числа с плавающей точкой в целое, так и для выполнения математических функций floor и cei 1. Компиляторы C++ и Fortran производства Intel сделают эти оптимизации за вас, если вы разрешите им использовать SSE- и 55Е2-команды с помощью флагов -QxW для Windows и -xW для Linux. Если у вас есть другие специальные математические функции или ассемблерный код, который изменяет управляющее слово для операций с плавающей точкой, то эти подпрограммы следует считать потенциальными кандидатами для улучшения. Регистр состояния MXCSR Когда в набор команд процессора Pentium III были добавлены SSE-команды, то был также добавлен и регистр управления и состояния MXCSR, предназначенный для управления SSE-командами аналогично тому, как регистр FPCW служит для управления командами FPU-блока х87. И подобно FPCW, запись в регистр MXCSR сериализует процессор, чтобы любые SSE/SSE2/SSE3-KOMaTObi, которые выполняются после записи в MXCSR, получили правильные значения управляющих флагов. Сериализация при частом ее выполнении вызывает большую потерю производительности, поэтому следует избегать записи в регистр MXCSR или, по крайней мере, минимизировать количество таких записей, чтобы не снижать производительность. Кэш данных уровня 1 Кэши данных уровня 1 на процессорах Pentium M, Pentium D и Pentium 4 организованы аналогично. Все они имеют восьмиканальную ассоциативность и строки размером по 64 байта, но размер кэша данных уровня 1 у них разный: у процессоров Pentium 4 и Pentium D — 16 Кбайт, а у процессора Pentium M — 32 Кбайт. Как уже
220 Глава 14 • Специфические для процессоров варианты оптимизации отмечалось, каждое из ядер процессора Pentium D имеет собственный кэш данных уровня 1. Наилучшим вариантом оптимизации кэша уровня 1 является оптимизация под самый малый размер кэша из числа тех процессоров, для которых вы разрабатываете приложение. При оптимизации под самый маленький размер кэша данных уровня 1 приложения хорошо работают также и на процессорах с более крупными кэшами данных уровня 1. Предвыборка памяти Оба процессора — Pentium 4 и Pentium M — имеют автоматическую аппаратную предвыборку. Обычно механизмы аппаратной предвыборки более эффективны, чем команда программной предвыборки, поэтому лучше ее не использовать. В тех случаях, когда выполняются все приведенные далее условия, автоматическая предвыборка процессора справляется с выявлением загрузок, и программная предвыборка не нужна: □ Только один поток выполняет обращение к 4-килобайтной странице (независимо от того, производит он чтение или запись). □ Выполняется не более восьми потоков из восьми разных 4-килобайтных страниц. □ Обращение производится к кэшируемой памяти, а не к памяти с объединенной записью или к некэшируемой памяти. Вы можете определить, где процессор Pentium 4 выполняет автоматическую предвыборку данных, сравнивая счетчики Bus Accesses и Reads Non-Prefetch в анализаторе VTune. Для процессора Pentium M количество автоматических предвыборок показывает счетчик Upward Prefetches Issued. События процессора При помощи таких инструментов, как анализатор VTune, можно производить выборку более 100 событий процессора. Все процессоры имеют один и тот же основной набор событий, например, событий неверно предсказанных переходов и промахов кэша, а также специфичных для данного процессора событий. В томе 3 руководства [20] можно найти подробности относительно счетчиков событий для процессоров IA-32 (см. приложение А), для процессоров Pentium 4 и Intel Xeon (см. раздел А.1) и для процессоров Pentium М (см. раздел А.2). Частичный останов регистров Частичные остановы регистров являются проблемой процессора Pentium M. Останов происходит тогда, когда за операцией записи в часть регистра следует
Частичный останов регистров 221 использование всего регистра целиком. На рис. 14.3 показано как регистры общего назначения делятся на части. ЕАХ АН AL Регистры общего назначения: ЕАХ, ЕВХ, ЕСХ, EDX Рис. 14.3. Части регистра общего назначения Следующий код вызывает частичный останов регистра на процессоре Pentium M: mov al. 20 ; устанавливаются только младшие 8 бит ЕАХ mov variable, eax ; останов при попытке получить 32 бит из ЕАХ Первая строка загружает младшие 8 бит регистра ЕАХ, а затем вторая строка использует весь 32-разрядный регистр. Проблема возникает потому, что процессор не знает, какие данные содержатся в старших 24 битах, поэтому он ждет полного завершения первой команды. Если загрузить сразу весь регистр (32 бита), то процессору не нужно ждать завершения загрузки, как показано в следующем коде: mov eax. 20 ; устанавливается весь регистр ЕАХ mov variable, eax ; останова не происходит Другим способом избежания данной проблемы является обнуление регистра при помощи команды хог непосредственно перед установкой младших 8 бит регистра, как показано в этом фрагменте кода: хог eax. eax ; обнуляет регистр mov al. 20 ; загружает младшие 8 бит. при этом 24 старших бита ; гарантированно равны нулю после команды хог mov variable, eax ; останова нет На процессоре Pentium M очень важно избегать этих частичных остановов регистров, которые можно обнаружить при помощи счетчика Partial Register Stalls в анализаторе VTune. Процессор Pentium 4 не подвержен остановам такого типа. Однако для того, чтобы получить код с хорошей смесью команд, кода с частичными остановами регистров необходимо избегать. Процессор Pentium 4 имеет другую проблему производительности, связанную с частичным остановом регистров АН, ВН, СН и DH, а точнее — битами с 8 по 15 в регистрах общего назначения. Доступ к этим регистрам более дорогостоящий, чем к другим регистрам. Процессор Pentium 4 генерирует лишние микрооперации
222 Глава 14 • Специфические для процессоров варианты оптимизации для извлечения значения при чтении такого регистра, и при сохранении значения в один из этих регистров он тоже генерирует дополнительные микрооперации. Такое свойство вызывает лишние микрооперации и дополнительную латентность, и единственный способ избежать данной проблемы — не использовать эти регистры. Если вы применяете компилятор C++ или Fortran производства Intel, ни об одном из этих частичных остановов беспокоиться не нужно. Генерируемый этими компиляторами код не должен содержать последовательностей команд, вызывающих частичный останов регистров. Однако если вы разрабатываете переносимое приложение, используете старый оптимизированный вручную ассемблерный код или работаете с устаревшим компилятором, остерегайтесь таких последовательностей. Они часто применялись в коде, оптимизированном для процессоров Intel486 или Pentium, и могут создаваться компиляторами, оптимизированными для этих устаревших процессоров производства Intel. Частичный останов регистра флагов На процессоре Pentium 4 при использовании команд, которые обновляют не все флаги (таких как INC и DEC, которые не обновляют флаг переноса CF), может произойти частичный останов регистра флагов. В этих случаях быстрее выполняются команды ADD и SUB. Команды INC и DEC особенно распространены в счетчиках циклов, поэтому при оптимизации всегда проверяйте циклы. Для получения универсального кода компиляторы производства Intel избегают вставлять команды INC и DEC, поэтому обычно о них беспокоиться не нужно. На процессоре Pentium M команды сдвига также могут вызвать частичный останов регистра флагов, если регистр флагов (созданный командой сдвига) управляет условным переходом с помощью команды SETCC или CM0VCC. Этот останов происходит потому, что команды сдвига могут не записать некоторые из флагов, если значение сдвига равно нулю, и при использовании флагов происходит частичный останов регистра флагов. Поэтому для получения кода с хорошей смесью команд флаги, созданные командой сдвига, не должны применяться в последующих командах. Компиляторы Intel знают об этой потере производительности и генерируют явные команды сравнения или проверки вместо использования флагов, созданных командой сдвига. Команда PAUSE была введена в процессоре Pentium 4, но она может выполняться на всех процессорах, поскольку все они интерпретируют команду PAUSE как NOP. Команду PAUSE следует добавлять в циклы ожидания, чтобы избежать возможных проблем с порядком записи в память и снизить потребляемую мощность. Команда PAUSE по сути является командой NOP с небольшой задержкой, которая ограничивает запросы к памяти максимальной скоростью шины памяти (это максимальная скорость, с которой память может быть изменена другим процессором). Пытаться выдавать запросы быстрее этого предела бесполезно. Следующий код содержит команду PAUSE, выполняемую при помощи внутренних команд компилятора C++: while (sync_var != READY) _mm_pause(); // выдает команду PAUSE
Основные моменты 223 Основные моменты При разработке кода, специфичного для конкретного процессора, помните следующее: □ Варианты оптимизации, нацеленные на процессор Pentium 4, отлично работают и на процессорах Pentium M и Pentium D. □ Оптимизируйте использование кэша и памяти на обоих процессорах с учетом того, что разница в размере кэша уровня 1 и буферов записи у них небольшая. □ Избегайте частичных остановов регистров и флагов.
Основы многопроцессорной обработки При многопроцессорной обработке в системе функционирует более одного процессора. В теории при использовании двух процессоров вместо одного производительность должна была бы удваиваться. В действительности так бывает не всегда, хотя в некоторых условиях многопроцессорная обработка и может привести к росту производительности. Вообще говоря, многопроцессорной обработки можно добиться пятью различными способами за счет применения широкого спектра вариантов параллелизма (начиная от параллелизма на уровне команд), как показано на рис. 15.1. Многопроцессорная обработка Параллелизм на уровне команд Гипер- Два ядра, поточность одна микросхема Процессор Pentium 4 □ □ Выполняет несколько команд одновременно Выполняет два Выполняет программных работу потока двух с разделением программных ресурсов потоков микросхемы Процессор Pentium 4 Процессор Pentium 4 Два процессора, один компьютер Распределенная обработка Процессор Pentium 4 Процессор Pentium 4 Процессор Pentium 4 Процессор Pentium 4 Два компьютера Меньше параллелизма Больше параллелизма Рис. 15.1. Пять способов параллельного выполнения команд Еще недавно при параллельном выполнении компьютеры использовали параллелизм на уровне команд, но теперь ситуация изменилась. Технология гиперпоточности, реализованная в системах на базе процессоров Pentium 4 и Хеоп производства Intel, позволяет одному процессору одновременно выполнять команды двух
Параллельное программирование 225 программных потоков — это снижает стоимость многопроцессорной обработки и повышает ее доступность. В другой прогрессивной технологии, благодаря которой компания Intel перешла к многоядерной архитектуре, в каждом процессоре используется два или более «мозга». Проще говоря, в многоядерной процессорной архитектуре разработчики поместили в один процессор два (или более) исполнительных ядра (вычислительных «движка»). Такой многоядерный процессор устанавливается прямо в гнездо для обычного процессора, но операционная система воспринимает каждое его ядро как отдельный процессор со всеми исполнительными ресурсами. Процессор, который поддерживает параллелизм на уровне потоков, способен выполнять совершенно разные программные потоки. То есть процессор может обрабатывать один поток приложения, а другой — операционной системы, либо параллельные потоки одного приложения. Программное обеспечение, функционирующее на одноядерных процессорах Intel, работает также и на двухъядерных процессорах Intel, но сегодня для максимально эффективного использования многоядерного процессора предназначенное для него программное обеспечение должно быть написано таким образом, чтобы рабочая нагрузка распределялась между ядрами. Такой подход называется «эксплуатацией параллелизма на уровне потоков», или просто «многопоточностью». По мере того как многопроцессорные компьютеры и процессоры, поддерживающие технологию гиперпоточности, а также двухъядерные и многоядерные технологии, становятся все более распространенными, появляется смысл задействовать методы параллельной обработки в качестве стандартного подхода к повышению производительности. Данная глава знакомит вас с теми аспектами параллельной обработки, которые касаются производительности и проектирования программного обеспечения — это даст вам возможность подготовиться к изучению следующего материала. Параллельное программирование Параллелизм является ключевой технологией повышения производительности системы за счет выполнения более одного действия одновременно. Самое частое применение параллелизм находит в современных операционных системах, где он позволяет скрывать задержки на доступ к системным ресурсам. При оптимизации программного обеспечения параллелизм обеспечивает выполнение большего объема работы за меньшее время. Для отдельного процессора параллелизм на уровне команд позволяет выполнять более одной команды одновременно (как в случае процессора Pentium 4, который может выполнять шесть микроопераций одновременно). Параллелизм на уровне команд становится возможным только тогда, когда определенные команды выполняются в определенном порядке, при определенных зависимостях по данным и при наличии соответствующей микроархитектуры процессора. Более высокие уровни параллелизма
226 Глава 15 • Основы многопроцессорной обработки могут быть достигнуты за счет нескольких процессоров, гиперпоточности или многоядерных процессоров. Для многопроцессорных систем параллелизм достигается путем использования нескольких программных потоков в одном приложении. В некоторых случаях современный сложный компилятор может автоматически создать многопоточный код, однако чаще программисту нужно явно разработать параллельный алгоритм и кодировать его в параллельную программу. Для написания многопоточной программы вы должны определить задания, способные выполняться параллельно. После того как такие задания определены, нужно организовать данные таким образом, чтобы задания стали относительно независимыми. Другими словами, проблему необходимо подвергнуть декомпозиции по заданиям и данным. Обычно один из двух вариантов декомпозиции (по заданиям или по данным) является более очевидным и становится основным в разработке параллельной программы, что в свою очередь приводит к двум различным стратегиям написания многопоточной программы, параллелизму заданий и параллелизму данных. □ Параллелизм заданий. Крупномодульный параллелизм заданий очень часто встречается в приложениях для настольных компьютеров. Например, текстовый процессор выполняет задания, которые периодически сохраняют резервную копию документа, проверяют правописание и грамматику, обрабатывают ввод пользователя — все это делают разные задания. В других программах могут выполняться одинаковые задания, но с разными данными, например, сервер для каждого пользователя запускает отдельный программный поток. В других случаях одно задание делится на множество независимых подза- даний (мелкомодульный параллелизм). В каждом из этих случаев говорят, что приложение, программные потоки которого распределены по выполняемым ими заданиям, имеет параллелизм заданий. □ Параллелизм данных. Приложения, которые обрабатывают большие наборы данных, могут распределить вычисления между несколькими программными потоками по блокам данных. Например, если алгоритм использует в вычислении большую матрицу, то несколько потоков могут работать над независимыми частями матрицы, обеспечивая параллелизм данных. В этом случае именно данные становятся основой параллелизма. Оба типа параллелизма могут использоваться в одной программе, и ни один из них принципиально не лучше другого. Выбор следует делать, исходя из конкретного приложения. В обоих случаях главным является то, чтобы все процессоры были загружены — для этого необходимо балансировать нагрузку, создаваемую заданиями, и минимизировать издержки. Балансировка нагрузки достигается разработкой такого алгоритма, который обеспечивает равномерную загрузку процессоров. Если один процессор работает больше, чем остальные, то такая несбалансированная загрузка становится тормозом производительности. Минимизация издержек (возникающих при создании, контроле и синхронизации программных
Управление программными потоками 227 потоков) помогает процессору выполнять больше полезного кода. Чем больше работы выполняет независимо каждый поток, тем меньше издержки и выше производительность. Управление программными потоками В некоторых случаях компилятор, способный обеспечить параллелизм, может проанализировать код и автоматически создать многопоточную программу. Однако этот способ не очень хорошо работает с реальными приложениями, когда нужная компилятору информация во время компиляции отсутствует или не может быть извлечена компилятором. Поэтому чтобы разработать многопоточное приложение, вам, скорее всего, придется использовать многопоточный прикладной программный интерфейс (Application Program Interface, API). Существуют две категории многопоточных прикладных программных интерфейсов: высокоуровневые, например, ОрепМР, и низкоуровневые поточные библиотеки, такие как POSIX- или Win32-noTOKH. При помощи высокоуровневых схем (таких как ОрепМР) программист сообщает компилятору на абстрактном уровне, что необходимо делать с потоками, и оставляет низкоуровневые детали на усмотрение компилятора. Такой подход делает интерфейс ОрепМР гораздо более простым в использовании, но происходит это ценой утраты некоторых средств контроля и, возможно, некоторой доли производительности. Поточные библиотеки дают программисту полный контроль над управлением и синхронизацией потоков. К сожалению, полный контроль требует от программиста проработки всех деталей управления потоками, что усложняет применение потоков. Сравнение низкоуровневых поточных библиотек и высокоуровневых поточных прикладных программных интерфейсов напоминает сравнение программирования на языках ассемблера и C++: вы получаете немного больше контроля и, возможно, некоторую дополнительную производительность ценой дополнительных усилий при программировании и сопровождении. Высокоуровневая поточная обработка средствами ОрепМР ОрепМР является промышленным стандартом для набора прагм, переменных окружения и библиотек времени выполнения, которые сообщают компиляторам (C++ и Fortran) когда, где и как создавать многопоточный код. В ОрепМР используется модель ветвления-объединения, когда один главный поток порождает группу потоков, а в конце объединяет полученные результаты (рис. 15.2). После создания потоков они могут обрабатывать одну и ту же или разные секции кода (параллельные секции) в каждом потоке, позволяя программисту писать параллельные программы на уровне заданий. Особенно ярко ОрепМР проявляет свои возможности тогда, когда этот прикладной программный интерфейс используется для распределения работ между группами программных потоков. Самая часто встречающаяся форма распределения работ — это распределение итераций
228 Глава 15 • Основы многопроцессорной обработки цикла между группой потоков (это самый простой и распространенный вариант применения ОрепМР). Если все сделано правильно, то у распределения работ есть еще одно дополнительное преимущество — программист получает возможность встраивать параллелизм в приложение постепенно, создавая код, который семантически эквивалентен исходному последовательному коду. ^ Параллельные -^ области Рис. 15.2. Модель ветвления-объединения в ОрепМР Для того чтобы больше узнать об ОрепМР, изучите документацию на ОрепМР [25], которую можно найти на веб-сайте, посвященном ОрепМР (http://www.openmp.org/). Представленный далее код выполняет приближенное вычисление интеграла с использованием метода трапеций. Интегрируемая функция и пределы интегрирования выбраны, поэтому результирующий интеграл должен дать приближенное значение числа я. Фрагмент кода иллюстрирует три разных способа написать эту программу: без потоков, с явным использованием Win32- потоков и средствами ОрепМР. При применении компилятора C++ производства Intel компилируйте с флагом -Qopenmp (Windows) или -openmp (Linux). static long num_steps = 100000; double pijiothreads (void) { double step, x, pi, sum; int i; sum = 0.0; step = 1.0/num_steps; for (i-1; i<=num_steps; i++) { x - (i-0.5)*step; sum = sum + 4.0 / (1.0+x*x); } pi=step*sum;
Управление программными потоками 229 return pi; } ^define NUMJHREADS 2 HANDLE hThread[NUM_THREADS]; CRITICAL_SECTI0N hUpdateMutex; double step; double global_sum =0.0; DWORD WINAPI Pi(void *arg) { int i, start; double x, sum=0.0; start = *(int *)arg; step = 1.0/num_steps; for (i-stiart; i<=num_steps; i+=NUM_THREADS) { x = (i-0.5)*step; sum = sum + 4.0 / (1.0+x*x); } EnterCri ti calSecti on(&hUpdateMutex); global_sum += sum; LeaveCri ti calSecti on(&hUpdateMutex); return 0; //не используется double pi_Win32(void) { double pi; int i; DWORD threadID; int threadArg[NUM_THREADS]; InitializeCriticalSectionC&hUpdateMutex); for (i=0; i<NUM_THREADS; 1++) { threadArg[i] = i+1; hThread[i] - CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE )Pi, &threadArg[i], 0, &threadID);
230 Глава 15 • Основы многопроцессорной обработки } WaitForMultipleObjects (NUM_THREADS. hThread, TRUE, INFINITE); pi = global_sum * step; return pi; } #include <omp.h> double pi_OpenMP (void) { int i; double x, pi, sum = 0.0; step = 1.0/(double) num_steps; omp_set_num_threads(NUM_THREADS); #pragma omp parallel for reduction(+:sum) private(x) for (i=l; i<=num_steps; i++) { x = (i-0.5)*step; sum = sum + 4.0/(1.0+x*x); } pi = step * sum; return pi; } int main(int argc, char* argv[]) { printf ("nothreads pi = %f\n", pijiothreadsO); printf ("Win32 pi = Xf\n\ pi_Win320); printf ("OpenMP pi = *f\n". pi_0penMPO); return 0; } Низкоуровневая поточная обработка Программные потоки могут создаваться и завершаться при помощи функций CreateThread и ExitThread прикладного программного интерфейса Win32 или функций _begi nthread и _endthread С-библиотеки времени выполнения. Библиотека MFC (Microsoft Foundation Class) также предоставляет поточные функции, такие как AfxBegi nThread и AfxEndThread, которые используют объект CWi nThread.
Многопоточные задания 231 Все эти методы явным образом создают дополнительные программные потоки. Вы можете задействовать потоки для реализации либо параллелизма заданий в заданиях типа обслуживания пользовательского интерфейса или сохранения резервных данных, либо параллелизма данных при выполнении длительных вычислений над независимыми данными. Многопоточные задания При преобразовании последовательной программы так, чтобы она с высокой производительностью выполнялась несколькими процессорами, многоядерным процессором или процессором с поддержкой гиперпоточности, необходимо иметь в виду следующее: □ Сосредоточьтесь на горячих точках. Если вы преобразуете уже работающее приложение, обязательно используйте анализатор производительности, чтобы определить, какие части приложения имеет смысл подвергнуть многопоточной обработке. Не занимайтесь многопоточностью или оптимизацией тех частей приложения, в которых тратится немного времени (то есть холодных точек). Если приложение находится в стадии проектирования, исследуйте дорогостоящие задания, алгоритмы и вычисления, чтобы понять, как их можно эффективно реализовать средствами наиболее быстрых одно- и многопоточных решений. Проводите эксперименты по производительности, оценивая ожидаемые результаты по производительности. □ Крупномодульные и мелкомодульные потоки. Потоки, будь они организованы по заданиям или по данным, должны делать как можно больше работы. Не занимайтесь поточной обработкой операции копирования памяти внутри алгоритма, если можно подвергнуть поточной обработке весь алгоритм. Всегда старайтесь определить необходимое количество независимых потоков, способных выполнять самые большие задания. Слишком большое количество небольших потоков приведет к потерям производительности из-за издержек, связанных с созданием потоков и переключением заданий между ними. □ Балансировка нагрузки. В параллельных вычислениях старайтесь максимально загрузить все ресурсы. Если один процессор загружен больше, чем остальные, то этому процессору потребуется больше времени, чтобы завершить свою работу, и общая производительность программы будет ограничена. Программист должен тщательно проанализировать проблему, чтобы обеспечить равномерное распределение работы между потоками для балансировки нагрузки между всеми процессорами. Однако при этом не забывайте о необходимости минимизировать издержки синхронизации, то есть везде, где это возможно, нужно пытаться добиваться компромисса между балансировкой нагрузки и синхронизацией потоков. □ Минимизируйте синхронизацию. Потоки, которые выполняются независимо с минимальной синхронизацией, будут готовы выполняться чаще других
232 Глава 15 • Основы многопроцессорной обработки и поэтому дадут более высокую производительность. В некоторый момент потокам все же понадобится синхронизация, но чем меньше точек синхронизации будет, тем выше окажется производительность. □ Минимизируйте распределение памяти между процессорами. Хорошо, если память совместно используется несколькими потоками, но плохо, если несколькими процессорами. Когда память распределяется между процессорами, для сброса данных из кэша одного процессора и загрузки их в кэш другого процессора требуются дополнительные транзакции по шине, поскольку определенная область памяти может находиться одновременно в кэшах нескольких процессоров, но лишь в том случае, если все процессоры только читают память. После чтения с целью разрешения записи данная строка кэша прекращает свое существование в кэшах других процессоров. Более того, распределение памяти обычно влечет за собой дополнительную синхронизацию, что может также снизить производительность. Весьма желательным является распределение как можно меньшего объема памяти. □ Количество потоков. Оптимально, когда программа имеет по одному готовому к выполнению потоку на процессор. Во время выполнения ваше приложение может запросить операционную систему определить количество процессоров, с тем чтобы создать разумное количество потоков. Если ваше приложение создает слишком много потоков, эффективность может упасть, так как операционная система будет часто переключать задания между потоками. Если потоков будет слишком мало, будут простаивать процессоры. Проблемы многопоточности В дополнение ко всем проблемам производительности и стратегиям оптимизации, перечисленными в данной книге, вы должны знать о некоторых уникальных проблемах, характерных исключительно для параллельной обработки: □ Короткие циклы. Чтобы программные потоки были полезными, выполняемая ими работа должна перекрывать издержки, связанные с переключением заданий и синхронизацией. Поточная обработка коротких функций (наподобие копирования памяти) — это не очень хорошая идея, даже если эти функции легко программировать. Сосредоточьтесь на горячих точках, поглощающих много времени выполнения, а не на отдельных маленьких циклах, где издержки поточной обработки могут поглотить весь выигрыш в производительности. □ Издержки поточной обработки. Потоки могут оказаться дорогостоящими, если использовать их невнимательно. Создание потоков не должно выполняться внутри критических циклов, поскольку вызов функции создания потока — дорогостоящая операция. Более того, каждое переключение заданий стоит потери производительности. По мере увеличения количества потоков объем потерянного на переключение заданий времени также увеличивается. Оптимальным
Проблемы многопоточности 233 является наличие примерно такого количества готовых к выполнению потоков, сколько имеется процессоров. □ Ложное распределение. Ложное распределение памяти между потоками может быть дорогостоящим вследствие неэффективности кэша. Ложное распределение происходит только тогда, когда два или более процессоров обновляют разные байты памяти, оказавшиеся в одной строке кэша. Технически потоки не используют одну и ту же область памяти, но поскольку процессор работает с памятью блоками размером в строку кэша, в конечном итоге эти байты получаются общими. Так как несколько процессоров не могут кэшировать одну и ту же строку памяти одновременно, то общая строка кэша постоянно пересылается туда-сюда между процессорами, порождая промахи кэша и потенциально огромные значения латентности памяти. Важно обеспечить, чтобы ссылки на память из отдельных потоков указывали на разные (нераспределяемые) строки кэша. Этот принцип применим к кэшам всех уровней: 1, 2 и 3 (когда они имеются). Поддерживайте между обновлениями памяти из разных потоков дистанцию не менее 128 байт. □ Общая пропускная способность памяти. Хотя общее количество команд, которые могут выполняться системой, увеличивается с каждым дополнительным процессором, пропускная способность памяти и большинство аппаратных ресурсов остаются постоянными. Все процессоры должны коллективно расходовать фиксированную пропускную способность памяти. Недавно запущенный в производство двухъядерный процессор производства Intel имеет большую пропускную способность памяти по сравнению с предыдущими процессорами, но она быстро может стать пределом для приложений, интенсивно использующих память. Основная идея такова: если производительность однопоточного приложения упирается в память, то несколько потоков и процессоров тут не помогут. Многопоточность помогает тогда, когда производительность приложения упирается в вычислительные ресурсы, а не в память. □ Эффективность кэша, конфликты и пропускная способность памяти. Хорошая эффективность кэширования при наличии нескольких процессоров весьма важна, поскольку максимальная полоса пропускания шины остается постоянной. Передача лишних байтов по шине по любой причине (распределение памяти, конфликты, емкость, обязательные загрузки, свободные байты в кэше — вот только некоторые из причин) означает использование общей пропускной способности всех процессоров. Всегда дважды проверяйте эталоны доступа к памяти и эффективность кэша. □ Издержки синхронизации. Иногда потоки не являются совершенно независимыми, что заставляет программу обеспечивать некую связь, или синхронизацию, между ними. Синхронизация практически всегда заставляет один поток ждать другой. Ожидающие потоки не работают и поэтому уменьшают степень параллелизма вашего приложения. Более того, вызовы синхронизации из API могут быть дорогостоящими. Вы можете снизить издержки синхронизации за счет более эффективной декомпозиции заданий и данных вашего приложения.
234 Глава 15 • Основы многопроцессорной обработки □ Пинг-понг кэша. Механизм «пинг-понга» кэша напоминает механизм распределения памяти. Во время переходов от параллельных областей к последовательным (в модели ветвления-объединения) память, содержащаяся в процессорах команды, может быть сброшена из кэшей рабочих процессоров для использования в главном потоке. В тех случаях, когда переходы от параллельных к последовательным областям происходят часто, память может переходить как мячик в пинг-понге между главным потоком на одном процессоре и потоками группы на других процессорах, растрачивая полосу пропускания шины и время. Для минимизации эффекта пинг-понга кэша уменьшайте количество переходов от параллельных к последовательным областям и ограничивайте объем памяти, которая распределяется между главным потоком и потоками группы в последовательных областях вашего приложения. □ Привязка к процессорам. Привязка к процессорам позволяет указать конкретные процессоры, на которых должны выполняться потоки. Если поток «прыгает» между процессорами, как показано на рис. 15.3, это может снизить производительность. На рисунке задача 1 выполняется на обоих процессорах, что требует перемещения туда и обратно также и данных этого задания, находящихся в кэше. Задача 1 Задача 2 Задача 3 Кванты времени Рис. 15.3. Потоки могут переходить между процессорами, снижая производительность Когда потоки не выполняются на одном и том же процессоре, использование кэша может стать проблемой. В Win32 привязка к процессорам может быть выполнена при помощи функций SetThreadAffi nityMask и SetProcessAf f i ni tyMask. К сожалению, в ОрепМР никаких прагм для привязки нет. Отсутствие привязки к процессору не означает автоматически более низкой производительности. Производительность больше зависит от того, сколько всего потоков и процессов выполняется, и насколько хорошо сбалансирована нагрузка потоков, а не от того, какой процессор выполняет код. Тестирование производительности приложения является единственным способом убедиться в том, сказывается на производительности отсутствие привязки или нет. □ Циклы ожидания и вызовы операционной системы. Есть множество вариантов синхронизации потоков, но ни один из них не является универсальным. Иногда лучшим вариантом синхронизации является вызов операционной системы, в других случаях лучше ожидать, используя простой цикл.
Поточные компиляторы и инструменты 235 Цикл ожидания — это простой цикл, который периодически проверяет область памяти в ожидании изменения значения переменной перед тем, как продолжить выполнение (как показано в следующем коде с помощью специальной встроенной функции _mm_pause() компилятора Intel): while (BufferFull != TRUE) { _mm_pause(); } Поскольку функция BufferFul 1 определена с ключевым словом vol ati 1 e (см. далее), то компилятор постоянно загружает значение из памяти, ожидая, что в конце концов значение переменной будет изменено операционной системой, аппаратной частью или неким параллельно выполняющимся потоком. Вот определение функции Buf ferFul 1: int volatile BufferFull; К сожалению, циклы ожидания поддерживают поток в активном состоянии, при этом на него расходуются вычислительные ресурсы. Если бы поток был приостановлен, то мог бы выполняться другой поток. Поэтому циклы ожидания хороши только для короткого ожидания, меньшего, чем временные издержки на вызов операционной системы. Кроме того, при написании цикла ожидания следует использовать команду PAUSE или специальную встроенную функцию _mm_pause() компилятора. Подробности относительно команды PAUSE см. в главе 13. Поточные компиляторы и инструменты Для помощи в оптимизации параллельного программного обеспечения и его проверки имеется целый набор инструментов производства Intel, таких как компиляторы C++ и Fortran, контроллер потоков и профилировщик потоков. Все эти инструменты быстро развиваются, чтобы соответствовать возможностям новых многоядерных процессоров и процессоров с поддержкой гиперпоточности. Кроме того, есть несколько важных концепций, которые касаются эффективной многопо- точности и которые всегда актуальны для всех параллельных программ. Проблемы, относящиеся ко всем программам (вроде медленных алгоритмов и латентности памяти), относятся также к параллельным программам. Такие инструменты, как анализатор VTune™, профилировщик потоков от Intel и монитора производительности от Microsoft, можно применять к многопоточным приложениям, чтобы выявлять проблемы, связанные, например, с горячими в отношении расходования времени точками, горячими алгоритмами, промахами кэша, неверным предсказанием переходов, балансировкой нагрузки и переключением заданий. Этими проблемами необходимо заниматься независимо от количества потоков, созданных в приложении. Следующим шагом в оптимизации параллельных программ является анализ того, насколько хорошо сбалансировано приложение. Глобальное представление о балансировке приложения может быть получено при помощи монитора производительности от Microsoft путем отслеживания значения счетчика % Processor Time
236 Глава 15 • Основы многопроцессорной обработки отдельно для каждого процессора. Это представление немедленно покажет, какие процессоры активны, а какие простаивают. Более подробное изучение потоков приложения можно выполнить путем построения графа вызовов с помощью анализатора VTune и профилировщика потоков производства Intel. Масштабируемость является важным аспектом производительности параллельной программы. Обычно добавление второго процессора дает очень хороший прирост производительности. В то же время добавление восьмого процессора не даст большого выигрыша. Эта разница является следствием многих факторов, таких как ограниченная пропускная способность шины и дополнительная синхронизация. Профилировщик для ОрепМР производства Intel способен анализировать проблемы масштабируемости. Для получения более подробной информации относительно текущих версий инструментов повышения производительности и отладчиков для ОрепМР вы можете обратиться на веб-сайт, посвященный ОрепМР [25], http://www.openmp.org/. Основные моменты Следуйте простым рекомендациям: □ Позвольте компиляторным технологиям (таким как ОрепМР, расширенная очередь заданий, автоматический параллелизм) помочь вам встроить поточную обработку в ваше приложение. Это сделает приложение более простым в написании и отладке. □ Позвольте поточным инструментам производства Intel, таким как контроллер потоков и профилировщик потоков, помочь вам в отладке и анализе ваших поточных приложений. □ Помните о полосе пропускания памяти и избегайте записи в общую память (во избежание ложного распределения), помните также обо всех проблемах эффективности кэша. □ Держите потоки в готовности за счет балансировки нагрузки и минимальной синхронизации.
Реализация многопоточности средствами Open MP Разработчики программного обеспечения, занимавшиеся вопросами производительности своих программ, знают, что горячие точки обычно представляют собой циклы внутри программ, и один из самых простых способов устранить эти горячие точки — выполнить декомпозицию по данным, чтобы распределить работу цикла между несколькими программными потоками. При использовании прикладных программных интерфейсов (поточных в Win32 или Pthread в Linux) эта простая схема имеет один недостаток, кроящийся в низкоуровневых деталях реализации потоков! А именно: кто-то должен создавать потоки, отображать их на процессоры, управлять ими, определять, сколько нужно создавать потоков, исходя из ресурсов системы. Этот недостаток несущественен, если вопросы себестоимости и переносимости вашего приложения для вас не важны, поскольку вы уверены, что код будет выполняться только на определенной системе. На практике же, естественно, для разработчиков важны и себестоимость, и переносимость, и удобство сопровождения, и производительность. ОрепМР решает все эти проблемы, привнося в оптимизацию параллельных программ и переносимость, и простоту. Ключевые элементы спецификации ОрепМР В языках C/C++ спецификацию ОрепМР составляют прагмы, прикладные программные интерфейсы и переменные окружения, которые поддерживаются компиляторами на широком диапазоне платформ. Компиляторы, которые не поддерживают ОрепМР, воспринимают инструкции прагм как комментарии (в соответствии с требованиями стандартов ANSI на языки С и C++). ОрепМР предоставляет аналогичную функциональность и в языке Fortran, только вместо прагм там используются директивы. Код, в котором содержатся прагмы, компилируется как однопоточный, если компилятор не поддерживает ОрепМР или в компиляторе отключен режим поддержки 16
238 Глава 16 • Реализация многопоточности средствами ОрепМР ОрепМР. Код компилируется как многопоточный, если компилятор поддержи] ОрепМР и в компиляторе включен режим поддержки ОрепМР. ОрепМР не требует изменения однопоточного кода для реализации многопоточности. Вам нужно толь ко постепенно добавлять директивы компилятора (в виде прагм), чтобы управлять компилятором при генерировании многопоточного кода. При отключении режима поддержки ОрепМР весь код сможет компилироваться и выполняться точно так же, как и ранее (если только код не слишком зависит от таких атрибутов, как идентификаторы потока и количество потоков). Используя поточные прикладные программные интерфейсы в Win32 или Linux, вы можете получить необходимую информацию от операционной системы на этапе выполнения и создать соответствующее количество потоков (см. главу 15). Однако этот процесс может быть неупорядоченным и чреватым ошибками. Более простое решение — позволить OpenMP-библиотеке времени выполнения, которая поставляется вместе с компилятором, определить правильное количество потоков и автоматически распределить работу. Вы можете также указать некое предопределенное количество потоков при помощи переменной окружения OMP_NUM_THREADS, инструкции numth reads или процедур OpenMP-библиотеки времени выполнения. При использовании вызовов библиотеки времени выполнения нужно быть внимательным, поскольку эти вызовы снижают переносимость исходной программы и вносят побочный эффект, поскольку усложняется постепенное достижение параллелизма в исходной программе. Поэтому мы всячески советуем пользователям ОрепМР задействовать прагмы и инструкции, такие как num_threads. Применение ОрепМР при помощи компилятора освобождает вас от необходимости заниматься низкоуровневыми деталями разбиения итерационного пространства, распределения данных, планирования потоков и синхронизации. С незначительными усилиями вы получаете ту производительность, на которую способны системы с общей памятью и несколькими процессорами, с многоядерными процессорами или с поддержкой гиперпоточности. Для того чтобы понять, как все это работает, рассмотрим файл исходного кода parfor.c, показанный в примере 16.1. Представленный здесь простой цикл for становится параллельным при помощи комбинированной прагмы рага 1 lei for. Пример 16.1. Параллельный цикл for, полученный средствами ОрепМР float х[1000]; void parfor(void) { int k; #pragma omp parallel for private(k) num_threads(4) for (k=0; k<1000; k++) { x[k] = sin(k*7.0) * 7.8 * k; } }
\ Ключевые элементы спецификации ОрепМР 239 Если компилировать файл исходного кода parfor.c под Windows с флагом -с, отключающим режим редактирования связей, то выдается одно диагностическое сообщение, связанное с переходом к параллельной обработке: icl -Qopenmp 0-c parfor.c •• 1 parfor\.c(4) : (col. 5) remark: OpenMP DEFINED LOOP WAS PARALLELIZED Параллельный цикл for в этом простом примере показывает, что ОрепМР может сделать для повышения производительности. Конкретные низкоуровневые операции, сгенерированные компилятором для прагмы parallel for, в этом примере не видны. При входе в параллельную область на этапе выполнения под управлением ОрепМР создается группа потоков. Все параллельные области заканчиваются барьером. На таком барьере программа ждет, пока все ОрепМР- потоки закончат выполняться. Эта пауза очень важна. В примере 16.1 вы, вероятно, не захотите продолжать, пока не закончится инициализация всего массива. Любой переход от параллельного к последовательному коду имеет неявный барьер. Однако иногда, если у вас выполняется несколько циклов, барьер между ними нежелателен. Было бы лучше, чтобы потоки одного цикла незамедлительно использовались как потоки второго (параллельного) цикла, следующего за первым. Сделать это можно, указав ключевое слово nowait для первого цикла, как показано в примере 16.2. Пример 16.2. Поддерживающий распределение работ цикл for float xQOOO]. y[1000]; void parfor(void) { int k; #pragma omp parallel private(k) num_threads(4) { #pragma omp for nowait for (k=0; k<1000; k++) { x[k] = sin(k*7.0) * 7.8 * k; } #pragma omp for schedule(guided, 100) for (k=0; k<1000; k++) { y[k] = exp(k*7.0) * 7.8 * k; } } }
240 Глава 16 • Реализация многопоточности средствами ОрепМР Ключевое слово nowait заставляет программу выполняться таким образом, что первый поток, который заканчивает свою работу, переходит ко второму циклу, не ожидая завершения другими потоками их циклов. Параллельное программирование имеет специальные термины для обозначения типов планирования i циклах из примера 16.2. Первый цикл for, поддерживающий распределение работ, по умолчанию выполняет статическое планирование, а второй — направляемое (детали см. в главе 17 в разделе, посвященном планированию циклов). / Конечно, не вся потенциально параллельная работа выполняется в циклах. Часто в программе имеются независимые задания или блоки прямолинейного кода, которые можно выполнять одновременно, назначая отдельные задания (или блоки кода) разным программным потокам. Этот прием называется функциональной декомпозицией, а поддерживается она в ОрепМР при помощи прагм sections и section. Рассмотрим использование прагмы paral 1 el sections в примере 16.3. Пример 16.3. Обеспечение параллельной работы прямолинейного кода средствами ОрепМР #pragma omp parallel sections { #pragma omp section { workA(dataA); } #pragma omp section { workB(dataB); } #pragma omp section { workC(dataC); } } Когда ОрепМР встречает прагму paral lei sections, то каждая единица работы назначается программному потоку, который в конечном итоге ее выполняет. Так же как и в случае с низкоуровневыми потоковыми прикладными программными интерфейсами, ОрепМР не дает никаких гарантий относительно планирования выполнения этих потоков. Блок workB(...) вполне может быть выполнен первым. Разработчики программного обеспечения, которые работали с многопоточными приложениями, знают, что если два или более потоков выполняются параллельно, то обязательно нужно использовать средства защиты, предотвращающие условия гонок и мертвые блокировки, которые являются настоящей «головной болью» любого разработчика параллельных программ. Вполне понятно, что ОрепМР предоставляет средства, предотвращающие обновление общего элемента данных несколькими потоками одновременно. Прагмы critical и atomic, показанные в примере 16.4, обозначают секцию кода, которая может выполняться только одним потоком в каждый момент времени.
Ключевые элементы спецификации ОрепМР 241 Пример 16.4. Блокирование обновлений общих переменных pragma omp critical х = foo(x+y) + goo(x-y); } #pragri(a omp atomic У * У + 1; Прагма critical реализует аналог идеи критических секций в том виде, в котором рни существуют в прикладных программных интерфейсах POSIX и Win32. Пока крд выполняется одним программным потоком, любой другой поток (который хочет выполнить его) должен ждать, пока первый поток достигнет закрывающей фигурной скобки. Скобки говорят ОрепМР, какую именно часть кода охватывает прагма. Помимо прагмы critical при обновлении общей переменной вы можете задействовать прагму atomic. В общем случае использование прагмы atomic позволяет получить более высокую производительность. Как видите из этих пояснений, ОрепМР перекрывает существенную часть функциональности, предлагаемой явными поточными прикладными программными интерфейсами. Однако высокоуровневая реализация ОрепМР требует для своей работы кода, который соответствует определенным требованиям. Инструкции OpenMP-прагм дают компилятору указание сгенерировать многопоточный код, который призван делать следующее: 1. Запустить надлежащее для времени выполнения количество потоков (в соответствии с переменной среды OMP_NUM_THREADS, инструкцией num_threads, вызовами библиотеки времени выполнения или системными ресурсами). 2. За счет разбиения и диспетчеризации (статически или во время выполнения) обеспечить распределение работ между этими потоками с помощью поддерживающих такое распределение цикла for или прагмы secti oris. 3. Обеспечить распределение и разбиение данных. 4. Выделить приватные области памяти. 5. Скопировать данные в каждый поток и из него. 6. Дождаться завершения потоков и затем приостановить работающие потоки. 7. Возвратиться в исходный поток. Для простых инструкций прагм это большая работа, и вся она делается при помощи OpenMP-компилятора и OpenMP-библиотеки времени выполнения, освобождая разработчика программного обеспечения от всяких забот по созданию и контролю потоков. Если исходный код не укладывается в предлагаемую ОрепМР программную модель обеспечения параллельности, то для преодоления ограничений ОрепМР вам может понадобиться приложить дополнительные усилия на преобразование вашего кода, чтобы сделать его более подходящим для параллельного выполнения.
242 Глава 16 • Реализация многопоточности средствами ОрепМР Многопоточная модель выполнения / В ОрепМР для реализации параллельного выполнения используется эффективная, хотя и простая модель ветвления-слияния. OpenMP-программа начинается^как один программный поток, называемый исходным потоком. Исходный поток выполняется последовательно, как если бы он содержался в неявной последовательной области, которая охватывает всю программу. Когда любой поток встречает параллельную конструкцию, он создает группу потоков, включающую сам исходный поток и некоторое количество дополнительных потоков. Это количество можзт быть равно нулю, если ветвление вложено в другое ветвление и реализация ОрепМР не поддерживает вложенного параллелизма. Исходный поток становится главным в новой группе. Все члены новой группы выполняют код внутри параллельной конструкции. В конце параллельной конструкции находится неявный барьер. Когда поток завершает свою работу внутри параллельной конструкции, он ждет у этого неявного барьера. Потоки могут покинуть параллельную область только когда все члены группы достигнут барьера. Однако лишь исходный (главный) поток продолжает выполнение кода за пределами параллельной конструкции. Любая параллельная область может находиться внутри другой. Если поддержка вложенного параллелизма отключена или он не поддерживается реализацией ОрепМР, то создаваемая группа состоит только из вошедшего потока. Однако если вложенный параллелизм поддерживается, то новая группа может состоять более чем из одного потока. Когда группа встречает конструкцию, поддерживающую распределение работ, задания внутри конструкции распределяются между членами группы. В конце конструкций, поддерживающих распределение работ, существует необязательный барьер. Выполнение кода каждым потоком группы возобновляется после завершения такой конструкции. ОрепМР предоставляет механизмы синхронизации и библиотечные процедуры, чтобы координировать потоки и данные внутри параллельных конструкций и конструкций, поддерживающих распределение работ. Кроме того, вы можете задействовать OpenMP-библиотеку и переменные среды для управления средой времени выполнения или запроса информации о среде времени выполнения в ОрепМР-программах. В ходе выполнения OpenMP-библиотека времени выполнения поддерживает пул потоков, которые могут служить рабочими потоками групп для параллельных областей. Когда исходный поток встречает параллельную конструкцию и должен создать группу потоков, то он проверяет пул и захватывает свободные потоки из пула, делая их рабочими потоками группы. Исходный поток может получить меньше рабочих потоков, чем ему нужно, если в пуле слишком мало свободных потоков. Когда группа заканчивает выполнение параллельной области, рабочие потоки возвращаются в пул. Как разработчик, вы должны тщательно обдумать, какие потоки подойдут для формирования группы. Если для формирования группы задействовать случайные потоки, тогда весь тот контекст, который данный конкретный поток наработал в предыдущей параллельной области (строки кэша, записи буфера ассоциативной
Ключевые элементы спецификации ОрепМР 243 трансляции, страницы виртуальной памяти и т. п.), вряд ли удастся использовать для следующей параллельной области. Во избежание такой ситуации ОрепМР- библиотека времени выполнения поддерживает горячую группу потоков на самом внешнем уровне параллелизма. Потоки из горячей группы сохраняются при переходе от одной внешней параллельной области к другой, при этом количество потоков остается прежним. Потоки, которые создаются OpenMP-библиотекой времени выполнения, не нужно уничтожать до завершения работы библиотеки, поскольку раннее их уничтожение увеличит издержки, если позднее они понадобятся. Вместо этого они присоединяются к пулу потоков и некоторое время ждут, а затем приостанавливаются, чтобы не занимать ценных ресурсов процессора — до тех пор, пока они не будут вызваны для присоединения к другой группе потоков. Модель памяти в ОрепМР До версии 2.5 в спецификации ОрепМР не существовало хорошо документированного раздела по модели памяти. Отсутствие четкой модели памяти не имело большого практического значения, поскольку компилятор обрабатывает прагму flush и подпрограммы блокирования в относительно консервативной манере, которая обеспечивает согласованность памяти. Однако по мере усложнения архитектур процессоров современные оптимизирующие компиляторы становятся более сложными и агрессивными. С учетом увеличивающегося количества многоядерных систем, систем с поддержкой гиперпоточности, систем с неоднородным доступом к памяти (cache-coherence Non-Uniform Memory Access, cc-NUMA) и кластерных систем очень важно, чтобы режим работы памяти в OpenMP-программах был четко описан. ОрепМР предоставляет модель общей памяти с нестрогой согласованностью для систем с несколькими процессорами, многоядерными процессорами и процессорами с поддержкой гиперпоточности. Предполагается, что память для сохранения и считывания данных доступна всем потокам. Каждый поток может иметь временное альтернативное место для хранения данных (такое как регистр) на случай, когда нет необходимости, чтобы эти данные были видны другим потокам. Данные могут перемещаться между памятью и временным местом хранения потока, но никогда не могут перемещаться непосредственно между местами временного хранения разных потоков, минуя память. В ОрепМР определяется два вида атрибутов доступа к памяти {общий доступ и приватный доступ) для переменных, используемых в структурированном блоке параллельного кода, таком как параллельная область. Каждая ссылка на общую переменную является ссылкой на область памяти этой переменной, и все потоки группы могут обращаться к этой области. Каждая ссылка на приватную переменную в блоке параллельного кода является ссылкой на приватную область памяти, к которой может обращаться только поток-владелец. Обратите внимание на это важное различие: приватная переменная во внешней параллельной области может быть общей переменной для всех потоков группы, которая создана для внутренней
244 Глава 16 • Реализация многопоточности средствами ОрепМР параллельной области, если только эта переменная не помечена как приватная по отношению к внутренней параллельной области. Любой другой доступ одного потока к приватным переменным другого потока приводит к непредсказуемому результату. Процедура на языке С, показанная в примере 16.5, содержит две вложенные параллельные конструкции. Внешняя параллельная область создает группу потоков, и каждый поток имеет свою приватную копию массива х. Массив указателей *p[N] содержит адрес каждого приватного массива х. Внутренняя параллельная область создает группу из одного потока, который является ее родительским потоком. Внутри внутренней параллельной области внутренний поток имеет приватный указатель. Если мы установим q = p[0], то все внутренние потоки внешних потоков 1, 2 и 3 смогут обращаться к приватному массиву х внешнего потока 0. Выполнение данного кода приводит к непредсказуемому результату, поскольку временное представление потока 0 не обязано быть согласованным по отношению к памяти для всех остальных потоков в любой момент времени. Пример 16.5. Недопустимое обращение к приватному массиву из другого потока #include<omp.h> #include<stdio.h> #define N 4 int mainO { int *p[N], x[N]; int t, m; #pragma omp parallel private(x.m) num_threads(N) { int t = omp_get_thread_num(); p[t] И &х[0]; for (m = 0; m < N; m++) p[t][m] = t; #pragma omp parallel shared(p.t) num_threads(l) { int *q - p[0]; // обращение к приватной переменной х из Thread О #pragma omp for private(m) for (m = 0; m < N; m++) { p[t][m] = q[m] * p[t][m] + 100; printf(«p[Sd][*d] = Xd\n», t, m, p[t][m]); } } } } Если вы измените для внутренней параллельной области инструкцию 1 nt*q = р[0] на i nt *q = p[t], то выполнение кода даст вполне конкретные результаты:
Ключевые элементы спецификации ОрепМР 245 р[0][0]=100 р[1][0]«102 р[2][0]=104 р[3][0]-106 р[0][1]=100 р[1][1]=102 р[2][1]-104 р[3][1]=106 р[0][2]=100 р[1][2]-102 р[2][2]=104 р[3][2]-106 р[0][3]=100 р[1][3]-102 р[2][3]=104 р[3][3]=106 Когерентность и согласованность являются двумя аспектами поведения систем памяти в параллельных программах с общей памятью. Когерентность относится к поведению системы памяти, когда несколько потоков обращаются к одной области памяти. Согласованность относится к порядку обращений к различным областям памяти, наблюдаемому из различных потоков системы. В ОрепМР не описывается режим когерентности, поскольку этот аспект спецификации оставлен базовым языку программирования и компьютерной системе. Таким образом, ОрепМР ничего не гарантирует относительно результата операций с памятью, которые создают условия гонок в программе. Однако ОрепМР гарантирует согласованность памяти за счет операции сброса. В нестрогой модели согласованности памяти значение, записанное в переменную, может оставаться во временном представлении потока до тех пор, пока оно не будет записано обратно в память в какой-то более поздний момент. Аналогично, чтение переменной может происходить из временного представления потока (если только чтение не будет выполнено принудительно из памяти). Операция сброса в ОрепМР принудительно обеспечивает согласованность между временным представлением и памятью. Операция сброса применяется к набору переменных, называемому набором сброса. Операция сброса ограничивает возможность переупорядочивания операций с памятью, что в противном случае могло бы произойти в ходе какой-нибудь оптимизации. Оптимизация для данной переменной не должна изменять код операции с памятью или код операции сброса (по отношению к операции сброса, в которой есть ссылка на ту же переменную). Для того чтобы передать данные из одного потока в другой, OpenMP-модели памяти требуется выполнить четыре действия: 1. Первый поток сохраняет данные в общей переменной. 2. Первый поток делает сброс переменной. 3. Второй поток делает сброс переменной. 4. Второй поток загружает данные из переменной. Эти ограничения на переупорядочивание по отношению к операциям сброса гарантируют следующее: □ Если пересечение наборов сброса двух операций сброса, выполняемых двумя различными потоками, не является пустым, тогда эти два сброса должны быть выполнены так, как будто они выполняются в неком последовательном порядке, видимом для всех потоков. □ Если пересечение наборов сброса двух операций сброса, выполняемых одним потоком, не является пустым, тогда два сброса должны казаться выполненными в программном порядке данного потока.
246 Глава 16 • Реализация многопоточности средствами ОрепМР □ Если пересечение наборов сброса двух операций сброса является пустым, тогда потоки могут наблюдать эти сбросы в любом порядке. Операция сброса и временное представление позволяют ОрепМР-реализациям оптимизировать операции записи и сохранения общих переменных. Рассмотрим код примера 16.6. Запись в переменную х может быть выполнена в строке s2 путем помещения значения 1,78 во временное представление х, а для использования переменной х в строке S3 можно загрузить ее значение из временного представления х в потоке 0. Однако если другой поток (например, поток 1) прочитает х через память перед выполнением flush(x) в строке s5 потоком 0, то значение х в памяти может оказаться несогласованным со значением во временном представлении потока 0. Следовательно, вы должны разбираться в модели памяти, чтобы разрабатывать правильные и эффективные OpenMP-программы и в то же время позволить компилятору реализовывать агрессивные варианты оптимизации этих программ. Пример 16.6. Пример использования прагмы flush х = 1.78 с = sin(2.0) + х #pragma omp flush(x) В некоторых случаях запись не обязательно должна завершаться до строки S5, когда она будет четко зафиксирована в памяти и доступна для всех остальных потоков. Если при оптимизации значение х попадет в регистр, то есть во временное представление памяти (или альтернативу памяти), то вместо выборки данных из памяти чтение х в S3 может быть выполнено из области временного хранения. Поэтому операция сброса и использование области временного хранения (вместе взятые) позволяют реализации скрывать задержки, вызванные как записями, так и загрузками. Сброс всех видимых переменных происходит при следующих условиях: □ при достижении области барьера; □ при входе в параллельные, критические, атомарные и упорядоченные области и выходе из них; □ при входе в комбинированные (параллельные и поддерживающие распределение работ) области и выходе из них; □ во время API-подпрограмм блокирования. Сбросы, связанные с подпрограммами блокирования, были добавлены в спецификацию ОрепМР версии 2.5 (ОрепМР 2005), что отличается как от спецификации ОрепМР C/C++ 2.0, так и от спецификации ОрепМР Fortran 2.O.
Ключевые элементы спецификации ОрепМР 247 Ограничения ОрепМР Не все циклы можно подвергнуть поточной обработке, просто добавив прагму paralI el for. Например, циклы, результаты которых используются в последующих итерациях того же самого цикла (эта ситуация, представленная в примере 16.7, называется циклической зависимостью), не будут правильно работать, если вы просто добавите прагму paral lei for. Поскольку компилятор встречает ОрепМР-прагму, он генерирует многопоточный код, не замечая ситуацию циклической зависимости. Поэтому сгенерированный код выдает неверные результаты. Пример 16.7. Неправильная поддержка параллелизма средствами ОрепМР int k, хСЮОО]: х[0] = х[1] = foo(0) + food) #pragma omp parallel for private(k) shared(x) for (k = 3; к < 1000; k++) { x[k] - x[k-l] + x[k-2] + foo(k) } Результат итерации цикла x[k] требуется в итерациях к + 1 и к + 2, поэтому данный цикл параллельным не является. Хотя стандарт ОрепМР не требует от компилятора анализа правильности кода и выявления такой зависимости, компилятор производства Intel дает пользователям возможность обнаруживать ошибки в OpenMP-программах. По сути, компилятор от Intel снабжает код инструментарием, после чего код поступает на многопоточную трассировку библиотеки времени выполнения контролера потоков Intel (программы Intel® Thread Checker) с целью выявления ошибок времени выполнения. В результате контролер потоков сообщает об ошибках в OpenMP-программах — в данном случае о циклической зависимости. Во многих случаях зависимости по данным менее очевидны, но их результаты все так же нежелательны. Точно так же условия гонок или другие проблемы поточной обработки могут привести к генерации кода, который работает неверно. То есть ОрепМР требует, чтобы разработчики делали свой код безопасным в отношении потоков. По сравнению с использованием прикладных программных интерфейсов (поточного в Win32 или POSIX в Linux), некоторые аспекты ОрепМР предоставляют разработчиками меньше низкоуровневых средств управления: □ ОрепМР не позволяет разработчикам изменить приоритет выполнения потоков. Другими словами, средствами ОрепМР управлять приоритетами отдельных потоков невозможно. □ Семафоры в ОрепМР не делают ничего, кроме блокирования (деблокирования) кода. Данные могут быть заблокированы или разблокированы несколькими потоками. □ ОрепМР не предоставляет разработчикам возможности мелкомодульного управления поточными операциями, например, использованием волокон
248 Глава 16 • Реализация многопоточности средствами ОрепМР (которые в Win32 доступны разработчикам через поточный прикладной программный интерфейс, позволяющий программистам разработать собственный планировщик потоков). В то же время значительную часть реализации поточной обработки ОрепМР берет на себя. Вы получаете простоту использования в обмен на несколько ограниченные возможности управления тем, что фактически происходит в многопоточной программе. На деле ОрепМР предоставляет очень мало информации о том, что происходит «за кулисами». В результате, если вам необходимо подстроить какую- либо функцию (например, изменить приоритет потока), вы не сможете сделать этого в ОрепМР. Компиляция ОрепМР-программ Для запуска компиляторов C++ и Fortran производства Intel в режиме ОрепМР запустите компилятор из командной строки с флагом -openmp для Linux или -Qopenmp для Windows. Под Windows компилятор C++ запускается для архитектур IA-32 и ЕМ64Т из командной строки следующим образом: icl -Qopenmp source.с Аналогично запускается компилятор Fortran производства Intel: ifort -Qopenmp source.f В табл. 16.1 представлены флаги запуска, необходимые для использования ОрепМР. Таблица 16.1. Флаги компиляторов для ОрепМР (C/C++ и Fortran) Windows -Qopenmp -Qopenmp-profile -Qopenmp-stubs -Qopenmp-report Linux -openmp -openmp-profile -openmp-stubs -openmp-report Семантика Генерировать многопоточный код для процессоров Pentium HI, Pentium 4 с поддержкой гиперпоточности, Pentium M и многоядерных Отредактировать связи с инструментальной OpenMP-библиотекой времени выполнения для создания профилировочной ОрепМР-информации с целью использования с профилировочным OpenMP-компонентом анализатора производи- тельности VTune™ Включить компиляцию ОрепМР-программ в последовательном режиме. OpenMP-директивы игнорируются и подключается ОрепМР-библиотека (последовательная) заглушек Управление уровнем выдаваемых сообщений:
Компиляция OpenMP-программ 249 Windows 0 1 2 Linux 0 1 2 Семантика Отключить диагностику параллельной обработки Сообщать об успешно распараллеленном коде (по умолчанию) Как при 1, а также сообщать об успешной генерации кода для прагм master, single, critical и atomic Перед запуском многопоточного кода вы можете задать желаемое количество потоков в ОрепМР при помощи переменной среды OMP_NUM_THREADS. Более подробную информацию относительно переменных среды в ОрепМР и расширенных процедурах компиляторов C++ и Fortran производства Intel можно найти на вебсайте продуктов Intel http://www.intel.com/products. На процессорах IA-32 OpenMP-флаги могут быть использованы с любым из флагов -Qx{KWBNP} (Windows) или -x{KWBNP} (Linux) для наборов команд, поддерживаемых соответственно процессорами Pentium® HI, Pentium 4, Pentium M, Pentium 4 с поддержкой гиперпоточности и многоядерными процессорами. С компилятором для ЕМ64ТОрепМР-флаги могут указываться только с флагами -Qx{WP} и -x{WP}. Необязательный символ а в этих специфичных для процессоров флагах (например, -QaxP или -ахР) включает режим автоматической координации процессора, чтобы во время выполнения программа выбрала соответствующую версию для того процессора, который фактически выполняет программу. Таким образом сгенерированный двоичный файл в плане производительности использует преимущества новых процессоров и по-прежнему правильно работает на более старых процессорах IA-32. Флаги -Qopenmp-report<ra> (Windows) и -openmp-report<ra> (Linux) управляют объемом диагностики в ОрепМР. Значение п = О полностью отключает вывод диагностики при компиляции. Значение п = 1 (по умолчанию) позволяет получить информацию по всем фрагментам кода, которые были успешно адаптированы для параллельного выполнения. В каждом диагностическом сообщении выдается название исходного файла с номерами строки и столбца первой инструкции во фрагменте кода, который был адаптирован для параллельного выполнения. Значение п = 2 обеспечивает выдачу информации для прагм master, single, critical и atomic, которые были успешно обработаны в программе, что может быть полезно, если требуется лучше приспособить программу для обработки средствами ОрепМР. Использование этих диагностических сообщений для проверки отношения количества конструкций, адаптированных для параллельного выполнения, к общему количеству конструкций программы дает меру качества генерации многопоточного кода. Диагностические сообщения в основном служат для того, чтобы убедиться, что компилятор делает именно то, чего вы от него ожидаете. ОрепМР работает на среднемодульном уровне детализации. Средствами ОрепМР можно выполнить в циклах декомпозицию по данным и назначить задания отдельным потокам. Если OpenMP-приложению необходимо выполнять сложные поточные операции, вам придется приложить дополнительные усилия, причем иногда для
250 Глава 16 • Реализация многопоточности средствами ОрепМР этого будет необходимо использовать совместно ОрепМР и низкоуровневые поточные прикладные программные интерфейсы. Более того, если ОрепМР-программа начнет работать некорректно, вы можете откомпилировать ее с флагом -Qtcheck (Windows) или -tcheck (Linux), чтобы запустить контролер потоков производства Intel, способный помочь вам распознать различные проблемы, такие как условия гонок, несинхронизированный ввод-вывод, мертвые блокировки и остановы, неинициализированные переменные, утечки памяти и переполнения массивов. Автоматический параллелизм В режиме автоматической адаптации кода для параллельного выполнения компиляторы производства Intel преобразуют последовательные части исходной программы в эквивалентный многопоточный код. При этом анализируется ход обработки данных в циклах программы и для них генерируется многопоточный код, который может эффективно и безопасно выполняться параллельно. Применяя этот режим, вы можете задействовать параллельные архитектуры в системах многоядерных процессоров, в системах процессоров с поддержкой гиперпоточности и в многопроцессорных системах. Автоматический параллелизм помогает минимизировать трудозатраты на введение параллелизма в программу, так как освобождает вас от необходимости: □ заниматься поиском циклов, которые являются хорошими кандидатами для распределения работ; □ анализировать ход параллельного выполнения программы с целью проверить его правильность; □ распределять данные для генерации многопоточного кода, как это требуется при программировании с помощью ОрепМР-директив. Поддержка автоматического параллелизма на этапе выполнения требует тех же функциональных возможностей, которые можно найти в ОрепМР, включая модификацию итераций циклов, планирование потоков, синхронизацию. Несмотря на то, что OpenMP-директивы обеспечивают быстрое преобразование последовательного приложения в параллельное, вам все-таки может потребоваться указать конкретные части кода приложения, обладающие параллелизмом, и добавить соответствующие директивы компилятора. В режиме автоматической адаптации кода для параллельного выполнения, который включается флагами компилятора, перечисленными в табл. 16.2, распознаются циклы, являющиеся кандидатами на переделку для параллельного выполнения на уровне потоков. Во время компиляции компилятор пытается автоматически выполнить декомпозицию последовательности кода в отдельные программные потоки для параллельной обработки. Следующие примеры показывают, как разделить пространство итераций цикла так, чтобы его части можно было выполнять одновременно в двух программных потоках.
Автоматический параллелизм 251 Таблица 16.2. Флаги компилятора для настройки автоматического параллелизма Windows -Qparallel -Qpar-threshold -Qpar-report Linux -parallel -par-threshold -par-report Описание Включает режим автоматической адаптации кода для параллельного выполнения Управляет порогом автоматической адаптации кода для параллельного выполнения Управляет диагностическими сообщениями в ходе автоматической адаптации кода для параллельного выполнения Пример 16.8. Разделение пространства итераций Исходный последовательный код: for СИ); i<1000; i++) { a[k] = a[k] + b[k] * f(k); // «f» не имеет побочных эффектов } Преобразованный параллельный код: // Поток 1 // Поток 2 for (k=0; k<500; k++) { for (k = 500; k<1000; k++) { a[k] = a[k]+ b[k]*f(k); a[k] = a[k]+ b[k]*f(k): } } При автоматической адаптации кода для параллельного выполнения компилятор может проанализировать ход выполнения в циклах с целью определить, какие циклы могут эффективно и безопасно выполняться параллельно. Цикл может быть переделан для параллельного выполнения, если он удовлетворяет следующим критериям: □ Во время компиляции цикл исчисляем. Выражение, определяющее, сколько раз цикл должен выполняться (называемое также количеством проходов, или счетчиком цикла), генерируется непосредственно перед входом в цикл. □ Программа не имеет циклических зависимостей по данным любого из следующих типов: загрузка после записи (потоковая зависимость ), запись после записи (выходная зависимость), запись после загрузки (антизависимость). Зависимость по данным между итерациями цикла имеет место тогда, когда к одной и той же области памяти происходит обращение (или обновление) во время разных итераций цикла. По усмотрению компилятора цикл может быть адаптирован для параллельного выполнения, если любые предполагаемые или препятствующие зависимости по данным между итерациями цикла могут быть устранены при тестировании зависимостей на этапе выполнения. Автоматический параллелизм может иногда привести к сокращению времени выполнения на многоядерных системах, многопроцессорных системах и системах
252 Глава 16 • Реализация многопоточности средствами ОрепМР с поддержкой гиперпоточности. Этот режим компилятора может помочь сократить время, затрачиваемое на решение некоторых задач, которые обычно приходится решать вручную: □ поиск циклов, которые являются хорошими кандидатами для параллельного выполнения; □ анализ хода параллельного выполнения кода с целью проверить его правильность; □ добавление вручную директив компилятора, обеспечивающих параллельное выполнение. Когда вы указываете пары флагов (такие как -openmp и -рага 1 lei для Linux или -Qopenmp и -Qparallel для Windows) в одной командной строке, компилятор автоматически реализует параллелизм в тех допустимых для параллельного выполнения циклах, которые не содержат OpenMP-директив (основываясь на анализе зависимостей и затрат). Кроме того, компилятор может создать тест времени выполнения на выгодность запуска в параллельном режиме цикла, параметры которого не являются постоянными при компиляции. Вы можете увеличить мощь и эффективность этого режима, следуя при кодировании некоторым рекомендациям: □ При каждой возможности объявляйте количество проходов циклов, а именно: используйте константы там, где количество проходов известно, и сохраняйте параметры циклов в локальных переменных. □ Старайтесь не размещать внутри тел циклов структур, внутри которых компилятор может предположить наличие зависимых данных. Например, не размещайте в них вызовы подпрограмм, неоднозначные косвенные ссылки или глобальные ссылки. Используйте ОрепМР в тех случаях, когда компилятор не может автоматически обеспечить параллельное выполнение тех циклов, про которые вы точно знаете, что они могут выполняться параллельно. ОрепМР является предпочтительным вариантом, поскольку разработчики программного обеспечения способны лучше, чем компилятор, разобраться в коде и эффективно реализовать параллелизм. Кроме того, если цикл может быть автоматически адаптирован для параллельного выполнения, то из этого не всегда следует, что это нужно делать. Компилятор использует значение порога автоматической адаптации кода, принимая решение о переделке цикла для параллельного выполнения. Вы можете настроить это значение при помощи флага -par_threshold для Linux или -Qparjthreshold для Windows. Значение порога меняется от 0 до 100, где 0 дает указание компилятору всегда реализовывать параллелизм для безопасного цикла, а 100 — реализовывать параллелизм только для тех циклов, у которых высока вероятность выигрыша в производительности. Вы можете также указать флаг компилятора -par_report (Linux) или -Qpar_report (Windows), чтобы выяснить, какие циклы были адаптированы для параллельного выполнения. Вдобавок компилятор сообщает о тех циклах, которые не могут быть выполнены параллельно, с указанием вероятной
Рекомендации по многопоточной обработке 253 причины этого. Дополнительную информацию см. в разделе «Auto-parallelization: Threshold Control and Diagnostics» документации на компиляторы производства Intel [16]. Рекомендации по многопоточной обработке Многие OpenMP-приложения зависят от входных наборов данных, которые могут быть очень разными, что откладывает решение о количестве используемых программных потоков до стадии выполнения (когда размеры входных данных уже можно определить). Примеры входных параметров, которые влияют на количество потоков: размеры матриц, размер базы данных, размер и разрешение изображения; глубина, ширина и сложность древовидных структур, размер списочных структур. Аналогично, когда OpenMP-приложения предназначены для компьютерных систем, количество процессоров которых может значительно меняться, необходимо откладывать решение относительно количества используемых потоков до момента выполнения приложения (когда количество процессоров уже можно определить). Для приложений, где объем работы невозможно предсказать, исходя из входных данных, подумайте о промежуточном этапе калибровки (что позволило бы узнать рабочую нагрузку и характеристики системы). Программа может использовать эту информацию для выбора соответствующего количества потоков. Если этап калибровки оказывается слишком дорогим, его результаты можно сохранить в постоянном месте (в файловой системе). Избегайте создавать больше программных потоков, чем количество процессоров в системе (имеются в виду потоки, которые одновременно могут быть активными). Такая ситуация приводит к мультиплексированию процессоров операционной системой, что обычно означает неоптимальную производительность. Производительность OpenMP-приложений в значительной степени зависит от: □ производительности исходного однопоточного кода; □ коэффициента использования процессоров, простоя потоков, привязки потоков и балансировки нагрузки; □ процентной части приложения, выполняемого в параллельном режиме; □ объема синхронизации и взаимодействия между потоками; □ конфликтов памяти, вызванных общей памятью или ложным разделением памяти. Издержки, связанные с созданием, контролем, уничтожением и синхронизацией потоков, усугубляются при любом увеличении количества переходов от последовательного к параллельному выполнению и обратно, называемых переходами ветвления-объединения. Аппаратные ограничения общих ресурсов (таких как память, пропускная способность шины и исполнительные устройства процессора) значительно влияют на возможность достижения оптимальной
254 Глава 16 • Реализация многопоточности средствами ОрепМР производительности средствами ОрепМР. Производительность многопоточного кода сводится к двум вещам: □ насколько хорошо работает однопоточная версия; □ насколько хорошо распределена работа между несколькими процессорами (с минимально возможным количеством издержек). Производительность всегда начинается с хорошо спроектированного параллельного алгоритма или приложения. Должно быть очевидно, что параллельная реализация пузырьковой сортировки (даже написанной на оптимизированном вручную ассемблере) — не слишком хорошая отправная точка. Помните о масштабируемости — создавать программу, которая хорошо работает на двух процессорах, не столь эффективно, как программу, способную хорошо работать на любом количестве процессоров. При использовании ОрепМР количество потоков выбирают компилятор и библиотека времени выполнения, поэтому крайне желательно писать программы, хорошо работающие вне зависимости от количества потоков. Архитектура производитель/потребитель редко бывает эффективной, поскольку она специально ориентирована на два потока. Когда алгоритм выбран, убедитесь в том, что код эффективно работает на целевой архитектуре Intel. Здесь большую помощь может оказать однопоточная версия. Для генерации однопоточной версии отключите параметры компилятора, относящиеся к ОрепМР. Затем прогоните однопоточную версию через заслуживающий доверия набор вариантов оптимизации. Когда добьетесь нужной производительности от однопоточной версии, наступает время сгенерировать многопоточную версию и начать анализ. Если вы не знаете, с чего начать, чтобы обеспечить параллелизм средствами ОрепМР, прибегните к поддерживаемому компиляторами C++ и Fortran производства Intel режиму автоматической адаптации кода для параллельного выполнения. Компилятор подскажет вам, был ли переделан тот или иной цикл, и сообщит причины, если не был. Если после завершения компиляции производительность оказалась не столь высокой, как вы ожидали, можете добавить прагмы и инструкции спецификации ОрепМР, чтобы продолжить тонкую настройку. Начните с исследования времени, проводимого операционной системой в циклах ожидания. Анализатор производительности VTune™ является мощным инструментом для такого исследования. Время простоя может означать несбалансированные нагрузки, а также множество заблокированных точек синхронизации и последовательных областей. Исправьте эти проблемы, а затем опять вернитесь к анализатору VTune для поиска лишних промахов кэша и проблем памяти, таких как ложное распределение. Разберитесь с этими основными проблемами, в результате у вас должна получиться хорошо оптимизированная параллельная программа, способная нормально выполняться на многоядерных процессорах, на процессорах с поддержкой гиперпоточности и на многопроцессорных системах. Оптимизация — это сочетание экспериментов и практики. Напишите небольшие программы, которые имитируют расходование вашим приложением ресурсов компьютера, чтобы иметь представление о том, какие варианты быстрее других.
Основные моменты 255 Испробуйте разные инструкции планирования параллельных циклов. Если издержки параллельной области велики по сравнению со временем вычислений, для управления параллелизмом можете попробовать вставить инструкцию i f (чтобы поочередно выполнять цикл или секций). При разработке библиотеки (в отличие от приложения) обеспечьте пользователю удобный механизм выбора количества потоков, применяемых библиотекой, поскольку, возможно, в приложении пользователя уже есть высокоуровневый параллелизм, который делает параллелизм в библиотеке ненужным или даже вредным. Наконец, что касается ОрепМР. Используйте инструкцию num_threads в параллельных областях, чтобы управлять количеством потоков; используйте инструкцию schedul e в параллельных циклах, чтобы настраивать и хорошо балансировать нагрузку; используйте инструкцию i f в параллельных областях, чтобы принять решение о том, нужна ли вообще многопоточность. Вы можете задействовать функцию omp_set_num_threads, но только в специальных и хорошо понятных ситуациях, поскольку ее глобальный эффект сохраняется даже после того, как текущая функция заканчивается (что может повлиять и на ее родителей в дереве вызовов). Инструкция num_threads по своему эффекту локальна, поэтому ее вызов не влияет на вызывающую среду. Кроме того, вы должны активно искать в вашем приложении возможности, чтобы усовершенствовать применение ОрепМР и создавать очереди заданий, а не ограничиваться простой схемой декомпозиции по заданиям и данным. Такие усовершенствованные методы включают в себя конвейерный параллелизм на уровне потоков, вложенный параллелизм и многоуровневый параллелизм (см. следующую главу). Основные моменты ОрепМР является мощным, переносимым и простым средством реализации многопоточной обработки в программах. Чтобы средствами ОрепМР получить максимальную производительность от параллелизма на компиляторах C++ и Fortran производства Intel, необходимо помнить следующее: □ Изучите самые часто используемые OpenMP-конструкции, модель памяти и флаги компилятора, имеющие отношение к ОрепМР □ Если вы новичок в ОрепМР-программировании, вы можете не знать, с чего и где начинать адаптацию вашего приложения для параллельной обработки. Первое, что вам необходимо сделать — реализовать автоматический параллелизм с целью выявить циклы, которые компилятор переделает автоматически, а затем продолжить работу по повышению производительности приложения с помощью прагм и инструкций, относящихся к спецификации ОрепМР. □ При возможности выбора между несколькими алгоритмами с одинаковой вычислительной сложностью выберите тот, который лучше всего поддается параллельной реализации средствами ОрепМР.
256 Глава 16 • Реализация многопоточности средствами ОрепМР □ Разберитесь в OpenMP-модели памяти и упростите те части программы, в которых требуются блокирование и взаимные исключения. Стремитесь к четкому стилю программирования, который минимизирует возможность появления неожиданных побочных эффектов параллелизма. □ Не забывайте, что ОрепМР — это не единственный выбор. Во многих программах одновременно используются и средства ОрепМР, и низкоуровневые поточные прикладные программные интерфейсы. Некоторые части программ, которые совместимы с OpenMP-потоками, задействуют именно их, тогда как остальные части применяют низкоуровневые библиотеки. Такой гибридный подход позволяет легко обеспечить многопоточное выполнение отдельных модулей и их переносимость. В данной главе вы познакомились с методами программирования средствами ОрепМР, а также с режимом автоматической реализации параллелизма компиляторов C++ и Fortran производства Intel. Читатели, которых интересуют детали компиляторных технологий параллелизма, могут обратиться к дополнительной литературе [35, 36].
Очередь заданий и другие сложные темы В данной главе обсуждается несколько сложных тем, связанных с ОрепМР, включая расширение от Intel для очередей заданий, конвейерный параллелизм на уровне потоков, вложенный параллелизм и многоуровневый параллелизм. Кроме того, в этой главе представлены некоторые идеи, касающиеся привязки потоков и разбиения циклов. Все описываемые приемы могут дать прирост производительности на платформах с многоядерными процессорами, платформах с поддержкой гиперпоточности и многопроцессорных платформах. Очередь заданий — расширение Intel для ОрепМР Разработчики программного обеспечения, знакомые с оптимизацией с прицелом на многопроцессорные системы, хорошо знают, что программы с динамическими структурами данных и/или со сложными управляющими структурами (такими как рекурсия) очень трудно эффективно адаптировать для параллельного выполнения. Очередь заданий (называемая также рабочей очередью) позволяет применить параллельное программирование к этим динамическим структурам данных и сложным управляющим структурам. Модель очереди заданий, поддерживаемая компиляторами C++ производства Intel, дает пользователям возможность задействовать необычный параллелизм, который выходит за пределы всего того, что включено в ОрепМР. Модель очереди заданий Модель очереди заданий дает пользователям возможность адаптировать для параллельного выполнения управляющие структуры, не поддерживаемые в текущей версии 2.5 стандарта ОрепМР, и в то же время сохранить соответствие приложения 17
258 Глава 17 • Очередь заданий и другие сложные темы общим рамкам, определенным в ОрепМР. В частности, модель очереди заданий является гибким механизмом указания единиц работы, которые невозможно вычислить к старту конструкции, поддерживающей распределение работ. Для таких OpenMP-конструкций, как single, for и sections, все единицы работы должны быть известны на момент начала выполнения конструкции. Прагмы очереди заданий taskq и task смягчают это ограничение, определяя, соответственно, среду, которая называется рабочей очередью, и единицы работы (задания). Прагма taskq описывает среду, в которой должны выполняться вложенные задания. На рис. 17.1 показано, как программа выполняет работу с помощью конструкций parallel, taskq и task. Когда встречается параллельная область, компилятор создает группу потоков. Из всех потоков, которые встречают прагму taskq, выбирается один поток, начинающий ее выполнение. Все остальные потоки должны ждать, пока работа не будет помещена в очередь заданий. Концептуально прагма taskq приводит к обработке выбранным потоком пустой очереди и ставит в очередь каждое встреченное задание. Затем выбранным начальным потоком в последовательном режиме выполняется код внутри блока taskq. Прагма task определяет единицу работы, которая потенциально может быть выполнена другим потоком. Когда прагма task лексически встречается внутри блока taskq, то код внутри блока task ставится в очередь, связанную с прагмой taskq. Эта абстрактная очередь расформировывается, когда заканчивается вся поставленная в нее работа и достигается конец блока taskq. jt- Главный поток Г ▼ * г ) г Рабочий поток Рабочий поток Рабочий поток Рис. 17.1. Модель очереди заданий В реальных приложениях в эталонах множества управляющих структур рабочие итерации отделены от самого создания работы, так что обеспечение их параллельного выполнения при помощи модели очереди заданий является вполне естественной. Обычными примерами являются итераторы языка C++, циклы while, рекурсивные функции. В частности, модель очереди заданий является гибкой программной моделью описания единиц работы, которые невозможно предварительно вычислить к старту конструкции, поддерживающей распределение работ, как в цикле wh i I e из примера 17.1.
Очередь заданий — расширение Intel для ОрепМР 259 Пример 17.1 Обеспечение параллельного выполнения цикла while с помощью очереди заданий void tq_foo(LIST *p) { #pragma intel omp parallel taskq shared(p) { while (p!= NULL) { #pragma intel omp task captureprivate(p) { tq_workl(p. 10); } #pragma intel omp task captureprivate(p) { tq_work2(p, 20); } p= p->next; } } } Условие цикла whi 1 e и любые изменения управляющих переменных размещаются снаружи блоков task и выполняются последовательно из-за зависимостей по данным управляющих переменных. STL-итераторы в C++ очень похожи на только что описанные циклы whi 1 е, поэтому операции с данными, сохраненными в STL, очень отличаются от итераций через все данные. Если операции в task (tq_workl и tq_work2) независимы по данным, то они могут выполняться параллельно до тех пор, пока итерация по task является последовательной. Более того, если вычисление в каждой итерации цикла whi 1 е независимы по данным, то весь цикл становится средой для прагмы taskq, и инструкции в теле цикла whi I e становятся единицами работы, которые необходимо описать при помощи прагмы task. Такой тип параллелизма цикла whi 1 е является обобщением стандартных поддерживающих распределение работ циклов for в ОрепМР. В таких циклах for операция инкремента цикла является итератором, а тело цикла — единицей работы. Однако поскольку переменная цикла for часто разрешается в аналитическом виде, ее можно вычислить параллельным образом и избежать этапа последовательного выполнения. Вы можете также использовать рекурсивные функции, чтобы указать области параллельных итераций. Этот механизм аналогичен введению параллелизма при помощи прагм sections, но он гораздо более гибкий по двум причинам. Во-первых, между прагмами taskq и task может находиться произвольный код. Во-вторых, рекурсивное вложение функции может создать абстрактное дерево очередей заданий. Рекурсивное вложение прагм taskq является концептуальным расширением OpenMP-конструкций, поддерживающих распределение работ. Поведение таких конструкций больше напоминает поведение вложенных параллельных областей в ОрепМР. Подобно вложенным параллельным областям, каждая вложенная конструкция taskq является новым экземпляром, и встречается она только одному потоку. Однако они отличаются в одном важном отношении:
260 Глава 17 • Очередь заданий и другие сложные темы вложенные конструкции taskq не вызывают формирования новых потоков или групп потоков. Вместо этого они повторно используют потоки из группы. Такое повторное использование потоков из группы позволяет очень легко реализовать мультиалгоритмический параллелизм в динамических средах. Количество потоков должно быть зафиксировано не на каждом уровне параллелизма, а только на самом верхнем уровне. Начиная с этого момента, если на внутреннем уровне внезапно появляется много работ, то свободные потоки с внешнего уровня могут помочь выполнить эти работы. Например, в серверных приложениях очень часто для обработки каждого входящего запроса выделяется отдельный поток, и большое количество потоков ожидает входящих запросов. Объем конкретного запроса может быть не вполне очевидным в тот момент, когда поток начинает его обрабатывать. Если поток использует вложенные конструкции taskq, и область видимости запроса возрастает после запуска внутренней конструкции, то потоки из внешней конструкции могут легко мигрировать во внутреннюю конструкцию, чтобы помочь в обработке запроса. Поскольку модель очереди заданий предназначена для сохранения последовательной семантики, то синхронизация является неотъемлемой частью семантики блока taskq. В конце блока taskq имеется неявный групповой барьер для потоков, которые встретили конструкцию taskq. Этот барьер обеспечивает завершение всех заданий, которые были указаны внутри блока taskq. Указанный барьер поддерживает последовательную семантику исходной программы. Так же как и в случае OpenMP-конструкций, поддерживающих распределение работ, ваше приложение должно удовлетворять одному из следующих условий: □ отсутствие зависимостей; □ зависимости должным образом синхронизированы между блоками task, либо между кодом в блоке task и кодом в блоке taskq снаружи блоков task. Конструкции tasq и task Синтаксис очереди заданий, семантика и допустимые инструкции очереди заданий напоминают OpenMP-конструкций, поддерживающие распределение работ. Большинство применимых к таким конструкциям инструкций применяются и с прагмами очереди заданий. Псевдокод и примечания в примерах 17.2,17.3 и 17.4 призваны проиллюстрировать синтаксис и семантику конструкций taskq и task, комбинированной конструкции paral I el taskq, а также применяемые инструкции. Пример 17.2. Конструкция taskq #pragma intel omp taskq [предложение^ ^предложение]...] с тру к туриров анный_блок Здесь аргумент предложение может быть одной из следующих конструкций: private( список_переменных ) firstpr-\vate(cnncoK_nepeMeHHbix )
Очередь заданий — расширение Intel для ОрепМР 261 1 a stрг i vate(список_переменных ) reduction( оператор : список_переменных ) ordered nowait Семантика pri vate(список_переменных ) Создать приватную (при помощи конструктора, предлагаемого по умолчанию) версию для каждого объекта в списке переменных для прагмы taskq. Для каждого вложенного блока task инструкция capturepri vate подразумевается. Исходный объект, на который ссылается каждая переменная, имеет при входе в конструкцию неопределенное значение, которое не должно изменяться внутри динамического пространства конструкции. Оно имеет неопределенное значение и при выходе из конструкции. firstprivate( список_переменных ) Создать приватную инициализируемую копированием версию для каждого объекта в списке переменных для прагмы taskq. Для каждого вложенного блока task инструкция capturepri vate подразумевается. Исходный объект, на который ссылается каждая переменная, не должен изменяться внутри динамического пространства конструкции, и он имеет неопределенное значение при выходе из конструкции. 1astpri vate(список_переменных ) Создать приватную (при помощи конструктора, предлагаемого по умолчанию) версию для каждого объекта в списке переменных для прагмы taskq. Для каждого вложенного блока task инструкция capturepri vate подразумевается. Исходный объект, на который ссылается каждая переменная, имеет при входе в конструкцию неопределенное значение, которое не должно изменяться внутри динамического пространства конструкции. Переменной путем копирования присваивается значение объекта из последнего вложенного блока task после того, как этот блок task завершит выполнение. reduction( оператор : список_переменных ) Выполнить при помощи указанного оператора операцию редукции во вложенных конструкциях task для каждого объекта из списка переменных. Определения для аргументов оператор и список переменных такие же, как в спецификациях ОрепМР [25]. ordered Выполнить упорядоченные конструкции во вложенных блоках task в исходном (последовательном) порядке. Директива taskq, с которой связано предложение ordered, должна иметь предложение ordered. nowait Ликвидировать неявный барьер в конце прагмы taskq. Потоки могут выходить из конструкции taskq раньше, чем завершаться все блоки task, поставленные в очередь заданий внутри конструкции taskq.
262 Глава 17 • Очередь заданий и другие сложные темы Пример 17.3. Конструкция task #pragma intel omp task [предложение[[,]предложение]...] структурированный_блок Здесь аргумент предложение может быть одной из следующих конструкций: private( список_переменных ) captureprivate( список_переменных ) Семантика private( список_переменных ) Создать приватную (при помощи конструктора, предлагаемого по умол чанию) версию для каждого объекта в списке переменных для прагмы task Исходный объект, на который ссылается переменная, имеет при входе в кон струкцию неопределенное значение, которое не должно изменяться внутр динамического пространства конструкции. Оно имеет неопределенное значени и при выходе из конструкции. captureprivate( список_переменных ) Создать приватную инициализируемую копированием версию для каждо1 объекта в списке переменных для прагмы task в момент постановки task в оч редь. Исходный объект, на который ссылается каждая переменная, сохраня свое значение, которое не должно изменяться внутри динамического простран ства конструкции task. Пример 17.4. Комбинированная конструкция parallel taskq #pragma intel omp parallel taskq [предложение^ .^предложение]. . .] с труктурированный_блок Здесь аргумент предложение может быть одной из следующих конструкций: if( скалярное_выражение ) num_threads( целое_выражение ) copyin( список__переменных ) default( shared | none ) shared( список_переменных ) private( список_переменных ) firstprivate( список_переменных ) lastprivate( список_переменных ) reduction оператор : список_переменных ) ordered Описания предложений одинаковы и для конструкции taskq, и для ОрепМР- конструкции paral lei. Для применения модели очереди заданий используйте поддержку ОрепМР в компиляторе C++ производства Intel. Читатели, которых интересует технология реализации очередей заданий в компиляторе, могут обра титься к дополнительной литературе [32].
Очередь заданий — расширение Intel для ОрепМР 263 Конкретный пример поточной обработки в программе п ферзей В этом примере прагмы очереди заданий используются для решения классической комбинаторной задачи п ферзей. В этой задаче требуется разместить п ферзей на шахматной доске размером п х п таким образом, чтобы ферзи не атаковали друг друга. Следовательно, два ферзя не могут находиться вместе в одном ряду, в одной колонке и в одной диагонали. В каждом ряду должно находиться по ферзю, и номера их колонок должны быть разными, так что решение может быть найдено перестановкой колонок. Однако не все перестановки являются решениями. Поиск решений для комбинаторной задачи, выполняемый в нашей реализации, известен под названием перебора с возвратами. Это название происходит от способа, которым наращиваются частичные решения — с возвратами из тупиков, где невозможно выполнить ограничения — до тех пор, пока не будет найдено одно или более полных решений. Основанный на последовательном коде, первоначально реализованным Кейтом Рендаллом (Keith Randall) из MIT (http://supertech.lcs.mit. edu/cilk/home/intro.html), наш параллельный код применяет прагмы очереди заданий для достижения параллелизма на уровне заданий с помощью компилятора производства Intel. Для того чтобы получить поддающиеся интерпретации результаты по производительности, программа компенсирует недетерминированную сущность типичных алгоритмов пространственного поиска, подсчитывая количество возможных решений (вместо того чтобы остановиться при нахождении первого решения). Вот этапы адаптации для параллельного выполнения процедур main и nqueens, показанных в примере 17.5: 1. Добавить три прагмы: paral 1 el, taskq и task для создания группы потоков и среды очереди заданий. Прагмы также ставят корневое задание дерева поиска в очередь на самом верхнем уровне (d = 0). 2. Добавить предложение редукции к прагме paral lei для вычисления общего количества найденных решений путем сложения значений счетчиков решений всех потоков. Эта переменная (sol_count) помечена как threadpri vate. 3. Создать клон процедуры nqueens под названием nqueens_par. В процедуре nqueens_par добавить прагму taskq для внешнего цикла по i и прагму task для тела цикла, так что каждая итерация цикла по i ставит в очередь заданий. Эти задания координируются и выполняются параллельно (для поиска уровня d) группой потоков, которая создается при входе в область paral lei, описанную в процедуре main. 4. Добавить переход для параллельного поиска в последовательной процедуре nqueens. To есть вызов процедуры nqueens_par выполняет параллельный поиск для размещения ферзя j на шахматной доске без конфликтов в том случае, если значение флага paral I el равно TRUE и уровень поиска d ниже предельного. В данной реализации а является массивом из j чисел. Элементы а содержат координаты положений уже установленных ферзей. Если расширение массива а приводит к полной расстановке п ферзей, то процедура помещает одну из этих расстановок (память для которых выделяется из кучи) в ряду, меньшем j, и колонке,
264 Глава 17 • Очередь заданий и другие сложные темы большей а [ j ], подсчитывает все полные решения задачи и обновляет переменную so~l_count (имеющую атрибут threadpri vate). Процедура no_conf l ict проверяет, можно ли удовлетворить ограничения для ферзя j. Она возвращает 1 в том случае если при размещении ферзя п в ряду, меньшем п, и колонке, большей а[п], не возникает конфликтов, в противном случае возвращается 0. Если код скомпилирован с флагом компилятора -DDBGJDUTPUT, то результат для задачи с пятью ферзями будет такой: 3] 2] 4] 3] 4] 0] 1] 0] 2] 1] Number of solutions: 10 Как показано в примере 17.5, рекурсивные вызовы функций производятся для выполнения параллельного поиска, описанного конструкциями taskqntask. Рекурсивное вложение конструкции taskq реализовано в процедуре nqueens и процедурах nqueen_par. Так же как и вложенные параллельные области, каждое динамически вложенное выполнение taskq является новым экземпляром, с которым встречается один поток. Однако вложенные конструкции taskq и task не вызывают формирования новых потоков или групп потоков. Вместо этого данные конструкции повторно используют потоки из группы, что дает возможность реализовать очень простой мультиалгоритмический параллелизм при динамическом расширении дерева поиска решения. Таким образом, количество потоков не нужно фиксировать на каждом уровне параллелизма, а только на самом верхнем уровне в процедуре main. Начиная с этого момента, если на внутреннем уровне внезапно появится много работы, то свободные потоки с внешнего уровня смогут помочь в ее выполнении. Solution # 1 Solution # 2 Solution # 3 Solution # 4 Solution # 5 Solution # 6 Solution # 7 Solution # 8 Solution # 9 Solution #10 [0, [0, [0. [0. [0, [0, [0, [0, [0. [0. 0] 0] 1] 1] 2] 2] 3] 3] 4] 4] [1 [1 [1 [1 [1 [1 [1 [1 [1 [1 2] 3] 3] 4] 0] 4] 0] 1] 1] 2] [2, [2, [2. [2, [2, [2, C2, [2, [2. [2. 4] 1] 0] 2] 3] 1] 2] 4] 3] 0] [3. [3. [3. [3. [3. [3, [3. [3. [3. [3, 1] [4 4] [4 2] [4 0] [4 1] C4 3] [4 4] [4 2] [4 0] [4 3] [4 Пример 17.5. Адаптация программы п ферзей для параллельного выполнения с помощью очереди заданий #include <malloc.h> #include <stdio.h> #include <omp.h> #define BOARD_SIZE 13 #define TASKQ DEPTH 4
Очередь заданий — расширение Intel для ОрепМР 265 int taskq_depth = TASKQ_DEPTH; int size - BOARD_SIZE; int parallel = 1; int sol_count - 0; /* число решений задачи */ #pragma omp threadprivate (sol_count) void nqueens_par(int n. int j, char *a, int d); int no_conflict(int n, char *a) { int i, j; char p, q; for (i =0; i < n; i++) { P = a[i]; for (j = i + 1; j < n; j++) { q = a[j]; if (q - p || q == p- (j - i) || q == p + (j - i)) return 0; } } return 1; } void nqueens(int n, int j, char *a, int d) { if (n - j) { #pragma omp critical { #ifdef DBG_OUTPUT int i; printf("Solution #$2d: ", sol_count+l); for (i = 0; i < size; i++) { printf("[2;2d,*2d] \ i, a[i]); } printf("\n"); #endif }
266 Глава 17 • Очередь заданий и другие сложные темы sol_count += 1; } /* перебор всех возможных положений ферзя <j> */ if (parallel && d < taskq_depth) { nqueens_par(n, j, a, d); } else { int i; for (i =0; i < n; i++) { /* выделить временный массив */ /* и скопировать а в него */ char* b = alloca((j + 1) * sizeof(char)); memcpy(b, a, j * sizeof(char)); b[j] = i; if (no_conflict(j + 1. b)) { nqueens(n, j + 1, b, d+1); } } } } void nqueens_par(int n, int j, char *a, int d) { #pragma intel omp taskq { int i; for (i =0; i < n; i++) { /* выделить временный массив */ /* и скопировать а в него */ #pragma intel omp task { char* b = alloca((j + 1) * sizeof(char)); memcpy(b, a, j * sizeof(char)); b[j] = i; if (no_conflict(j + 1, b)) { nqueens(n, j + 1, b, d+1); } } } } }
Очередь заданий — расширение Intel для ОрепМР 267 int main(int argc, char *argv[]) { char *a; int i; int total_cnt = 0; double start_time, end_time; a = alloca(size * sizeof(char)); start_time = omp_get_wtime( ); #pragma omp parallel reduction(+: total_cnt) { #pragma intel omp taskq #pragma intel omp task nqueens(size. 0, a, 0); total_cnt += sol_count; } end_time = omp_get_wtime( ); printf("Number of solutions: $d\n\n". total_count); printfC'Exec Time is Xlf seconds.\n\n", end_time-start_time); return 0; } В процедуре nqueens_par внешний цикл, связанный с прагмой taskq, демонстрирует семантику очереди заданий, то есть последовательная семантика цикла for сохраняется, как и задумано, в модели очереди заданий. Синхронизация является неотъемлемой частью семантики блока taskq. Неявный барьер группы находится в конце блока taskq (внешний цикл) для потоков, которые встретили конструкцию taskq — он обеспечивает завершение всех заданий, указанных внутри блока taskq. Этот барьер в taskq реализует последовательную семантику исходной программы. Подобно OpenMP-конструкциям, поддерживающим распределение работ, вы отвечаете за то, чтобы не было зависимостей по данным или чтобы зависимости были должным образом синхронизированы либо между блоками task, либо между кодом в блоке task и кодом в блоке taskq снаружи блоков task. Как параллельная, так и последовательная версии этих программ были скомпилированы компилятором C++ версии 9.0 производства Intel. Параллельные программы компилировались с флагами -Qopenmp -02, в то время как последовательные — с флагом -02. Параллельная версия, несомненно, быстрее. Время выполнения сократилось в 5,22 раза по сравнению с последовательным вариантом (на системе с четырьмя процессорами Intel Xeon®, работающими на частоте 1,6 ГГц, с кэшем уровня 1 размером 8 Кбайт, кэшем уровня 2 размером 256 Кбайт, кэшем уровня 3 размером
268 Глава 17 • Очередь заданий и другие сложные темы 1 Мбайт на каждый процессор и 2 Гбайт общей памяти на системной шине с частотой 400 МГц). Производительность замерялась с включенной и выключенной поддержкой гиперпоточности. Результаты проверки производительности, представленные в табл. 17.1, были получены при размере шахматной доски 13 х 13 и глубине параллельного поиска, равной 4. Таблица 17.1. Ускорение программы для 13 ферзей при помощи конструкций очереди заданий Последовательное выполнение 1,00х 1 поток без поддержки гиперпоточности 1,01х 2 потока без поддержки гиперпоточности 2,03х 4 потока без поддержки гиперпоточности 3,32х 8 потоков с поддержкой гиперпоточности 5,22х При замере производительности последовательного кода, а также поточного кода с 1, 2 и 4 потоками, сгенерированного компилятором производства Intel, поддержка гиперпоточности была отключена. В противном случае нельзя было гарантировать, что все потоки распланированы по разным физическим процессорам, поскольку при включенной поддержке гиперпоточности два потока не обязательно планируются по разным процессорам даже в том случае, когда количество потоков меньше количества физических процессоров. При отключенной поддержке гиперпоточности и четырех работающих потоках программа п ферзей ускоряется в 3,32 раза по сравнению с последовательным выполнением. Издержки многопоточного кода очень малы и благодаря контролю порогового значения незаметны. После включения поддержки гиперпоточности ускорение программы п ферзей составляет при восьми работающих потоках 5,22 раза. Таким образом, прирост производительности благодаря гиперпоточности для программы п ферзей составляет 57 %. Этот результат также показывает, что степень детализации (порог глубины очереди заданий), выбранная при адаптации данной программы для параллельного выполнения, является почти оптимальной для данной целевой архитектуры. Конвейерный параллелизм на уровне потоков На сборочной линии автомобильного завода автомобиль собирается поэтапно. На каждом этапе все больше частей монтируется на создаваемое изделие. Наконец, работающая машина съезжает с линии. Этот процесс образует конвейер, и сама идея весьма привлекательна, поскольку разные бригады могут выполнять конвейерные операции одновременно на многих частично собранных машинах. Пока первая бригада устанавливает шасси на одной машине, вторая монтирует двигатель на другой. Прелесть конвейера заключается в присущем ему параллелизме, поскольку многие задания могут выполняться одновременно. В компьютерах конвейерный параллелизм возможен тогда, когда несколько этапов зависят друг от друга, но их выполнение может перекрываться, а выходные данные одного этапа становятся входными данными для следующего.
Конвейерный параллелизм на уровне потоков 269 Конвейерный параллелизм на уровне потоков является примером среднемо- дульного параллелизма, поскольку задачи не полностью изолированы. То есть завершение одного этапа не дает окончательного результата. Кроме того, объем работы должен быть достаточно большим, чтобы части ее можно было выделить и назначить в качестве рабочих заданий. На рис. 17.2 показан пример 4-канального конвейерного параллелизма. В этом примере две сериализирующие зависимости: □ вычисление Sk потока Тп зависит от результата Sk потока Тп v где п = 1, 2, 3 и*«0,1,2,3,4,5,6; □ вычисление Skl потока Тп также зависит от результата Sk потока Тп, где п = О,1, 2, Зи&= 1,2,3,4,5,6. SO S1 S2 S3 S4 S5 S6 Рис. 17.2. Пример 4-канального конвейерного параллелизма В этой OpenMP-реализации на конвейере выполняются четыре программных потока, а работа распределяется по направлениям Г и 5. В ходе выполнения потоки отображаются на процессоры (ядра) в соотношении 1:1. Поток 0 начинает с верхнего левого угла и работает над вычислением первого ряда из S значений. Остальные потоки ждут готовности данных в направлении Г. Как только Г0 заканчивает свою работу, Тх может начать свою работу для того же самого ряда 5, а в это время Г0 переходит к следующему ряду 5. Этот процесс продолжается до тех пор, пока все потоки не станут активными. Далее все они работают параллельно до завершения. Функции post и wait обеспечивают синхронизацию между точками при помощи циклов ожидания и прагмы f 1 ush, которая используется именно там, где требуется синхронизация. Прагма f I ush гарантирует также согласованный вид памяти для всех потоков массива sync. Файл исходного кода pipeline.c, показанный в примере 17.6, для управления зависимостями реализует функции post и wait. Функция wait устанавливает ожидания по массиву sync. Функция post активизирует следующий шаг обработки массива sync после того, как текущий шаг заканчивается. Общий массив sync сигнализирует о наличии данных от потоков: 1 означает «готовы», а 0 — «не готовы». Прагма f I ush гарантирует, что значение массива sync актуально для всех потоков в точке, где находится прагма flush. Для каждого потока в направлении Г поток Тп ждет доступности данных потока Тп t (п > 0) и поддерживает зависимость в направлении 5.
270 Глава 17 • Очередь заданий и другие сложные темы Пример 17.6. Использование 4-канального конвейерного параллелизма #include <omp.h> #include <stdio.h> #define MAX_NUM_THREADS 4 #define MAX_NUM_STEPS 7 int y_data[MAX_NUM_THREADS][MAX_NUM_STEPS]; void wait(int tid, int nth, volatile int sync[]) { if (0 < tid && tid < nth) { while (sync[tid-l] == 0) { #pragma omp flush(sync) } sync[tid-l] = 0; #pragma omp flush(sync) } return; } void post(int tid, int nth, volatile int sync[]) { if (tid < nth-1) { while (sync[tid] == 1) { #pragma omp flush(sync) } sync[tid] = 1; #pragma omp flush(sync) } return; } void do_work(int s, int tid) { if (tid == 0 && s == 0) { y_data[tid][s] = s; else if (tid == 0 && 0 < s) { y_data[tid][s] = y_data[tid][s-l] + s; }
Конвейерный параллелизм на уровне потоков 271 else if (0 < tid && s — 0) { y_data[tid][s] = y_data[tid-l][s] + tid; } else { y_data[tid][s] - y_data[tid][s-l] + y_data[tid-l][s]; } int mainO { int s, tid, nth; int pipe_sync[MAX_NUM_THREADS]; t#pragma omp parallel private(s, tid, nth) \ num_threads(MAX_NUM_THREADS) { tid = omp_get_thread_num(); nth = omp_get_num_threads(); pipe_sync[tid] - 0; #pragma omp barrier for (s=0; s<MAX_NUM_STEPS; s++) { wait(tid, nth, pipe_sync); do_work(s, tid); post(tid, nth, pipe_sync); for (tid = 0; tid < MAX_NUM_THREADS; tid++) printfC'TW ", tid); for (s = 0; s < MAX_NUM_STEPS; s++) { printf("U2d M, y_data[tid][s]); } printf("\n"); Подпрограмма do_work выполняет вычисления, охватывающие обе зависимости направлений Г и S: y_data[Tl,S2] - y_data[Tl.Sl] + y_data[T0,S2] = 5 На рис. 17.3 представлены полученные результаты. В этом примере «работа» (загрузка, сложение, сохранение) проста, она лишь призвана показать, как работает конвейер с соблюдением всех зависимостей.
272 Глава 17 • Очередь заданий и другие сложные темы SO 0 1 3 6 SI 1 2 5 11 S2 3 5 10 21 S3 6 11 21 42 S4 10 21 42 84 S5 15 36 78 162 S6 21 57 135 297 Рис. 17.3. Результаты запуска примера с 4-канальным конвейерным параллелизмом Параллельная область в процедуре main создает группу из четырех потоков; каждый поток отображается на процессор (или ядро), чтобы выполнять назначенную ему работу параллельно с остальными потоками. Например, [Tl, SO] и [ТО, S1] выполняются параллельно потоками Т1 и ТО. Таким образом, вы можете эксплуатировать описанные методы, позволяющие использовать OpenMP-параллелизм сверх параллелизма на уровне задач и данных для тех приложений, которые легко проявляют свои зависимости. Представленная здесь схема применения функций post и wait обычно подходит ко всем приложениям, которым присущ конвейерный параллелизм. Помните, что успех зависит от того, насколько хорошо вы понимаете ограничения зависимостей вашего приложения на разных его уровнях (таких как цикл, область, секция) и издержки синхронизации. Всегда вместо разработки сложного параллельного алгоритма, требующей значительных трудозатрат, используйте простую схему из функций post и wait. Вложенный параллелизм Спецификация ОрепМР поддерживает вложенный параллелизм. Однако большинство существующих компиляторов ОрепМР поддерживает вложенный параллелизм не полностью, поскольку для OpenMP-совместимой реализации разрешается сериализовать внутренние параллельные области, даже тогда, когда вложенный параллелизм включен при помощи переменной среды OMP_NESTED или подпрограммы omp_set_nested(). Широкий класс приложений (таких как обработка изображений, алгоритмы кодирования и декодирования аудио и видео) показали прирост производительности при использовании вложенного параллелизма. В качестве примера можно привести систему аудиовизуального распознавания речи (Audio-Visual Speech Recognition, AVSR), состоящую из нескольких подсистем распознавания речи, использующих визуальную информацию совместно с аудиоинформацией. Эта система показала существенный рост производительности по сравнению со стандартными системами распознавания речи. На рис. 17.4 показана блок-схема AVSR-процесса. Наличие визуальной функции в AVSR обосновано бимодальностыо формирования речи и способностью людей лучше различать произносимые звуки тогда, когда имеется как аудио, так и видео составляющая.
Вложенный параллелизм 273 Ввод аудио и видео Обработка аудио Обработка видео AVSR-обработка Результаты распознавания Рис. 17.4. Процесс аудиовизуального распознавания речи Система аудиовизуального распознавания речи имеет четыре отдельных функциональных компонента, касающихся обработки аудио, видео, аудио-видео и всего остального. Поэтому естественной схемой AVSR-параллелизма является оформление функциональных компонентов в виде OpenMP-секций, поддерживающих распределение работ, как показано в псевдокоде примера 17.7. Аудио- и видео потоки могут разбиваться на блоки и обрабатываться на конвейере. Пока для текущего блока данных идет обработка аудио и видео, идет также AVSR-обработка для предыдущего блока данных. В примере для параллельного выполнения адаптируются не только параллельные, но и конвейерные задания. Пример 17.7. Многопоточное AVSR-приложение с поддержкой ОрепМР #pragma omp parallel sections { #pragma omp section { DispatchThreadProc( &AVSRThData );} // ввод данных #pragma omp section { AudioThreadProc( &AudioThData ); } // обработка аудиоданных #pragma omp section { VideoThreadProcC &VideoThData ); } // обработка видеоданных #pragma omp section { AVSRThreadProc( &AVSRThData ); } // выполнить AVSR } В дополнение к функциональной декомпозиции AVSR-приложения в примере эксплуатируется вложенный параллелизм данных в динамическом содержимом секции, или потока, обработки видео. Мотивом для разбиения этого программного потока на несколько потоков является желание добиться лучшей балансировки нагрузки. На рис. 17.5 показано распределение времени выполнения по нагрузке AVSR-приложения, где видно, что обработка видео занимает примерно половину всего времени.
274 Глава 17 • Очередь заданий и другие сложные темы Обработка Другие аудио задания 2,5 % 8,8 °/(> Обработка аудио-видео 36,6 % Обработка видео 52,1 % Рис. 17.5. Распределение времени выполнения AVSR-приложения Для эксплуатации в приложения параллелизма на уровне заданий на одном процессоре с поддержкой гиперпоточности или на двухпроцессорной системе без поддержки гиперпоточности рабочая нагрузка может быть хорошо сбалансирована путем размещения программного потока для обработки видео на одном процессоре, а остальных потоков — на другом. Однако на двухъядерном процессоре с поддержкой гиперпоточности или двухъядерной двухпроцессорной системе чисто функциональная декомпозиция не поможет сбалансировать нагрузку, поскольку обработка видео занимает около половины всего времени выполнения. При дальнейшей оптимизации скалярное произведение матриц/векторов и преобразование Фурье выделяются в отдельные программные потоки, как показано в псевдокоде примера 17.8. Пример 17.8. Использование вложенного параллелизма для обработки видео в AVSR-приложении // Ядро вычисления скалярного произведения // вызывается в параллельных областях omp_set_nested( 1 ); call dot-product of matrix and vector kernel // Для скалярного произведения матрицы и вектора float **matrix; // входная матрица float *vector, // входной вектор *result; // вектор результата int rows, columns; // В данном примере количество рядов равно 480,
Вложенный параллелизм 275 // поэтому мы установим размер блока в 120 // и используем статическое планирование для каждого потока #pragma omp parallel for num_threads(4) \ schedule(static, 120) for (int i=0; i<rows; i++) { ippmDotProduct_vv_32f(matrix[i], vector, &(result[i]), columns); } На рис. 17.6 показана реализация параллелизма в AVSR-приложении при помощи OpenMP-прагм, обеспечивающих эксплуатацию параллелизма заданий и данных. На рисунке А означает обработку аудио, V— обработку видео, AV — обработку аудио-видео, а О — прочую обработку. Диаграмма слева иллюстрирует многопоточную модель, когда у вас есть только четыре потока, полученные после функциональной декомпозиции. Диаграммы в центре и справа иллюстрируют вложенный параллелизм (когда обработка видео производится двумя или четырьмя программными потоками соответственно). Нижние узлы обозначают дополнительные потоки, которые создаются для выполнения параллельного цикла for внутри динамического пространства параллельных областей. А V AV О Рис. 17.6. Поточные схемы реализация параллелизма в AVSR-приложении средствами ОрепМР Для оценки эффективности вложенного параллелизма производительность мультимедийного AVSR-приложения была измерена на системе с двумя процессорами Intel Xeon с поддержкой гиперпоточности на частоте 2,0 ГГц, 1024 Мбайт памяти, кэшем уровня 1 размером 8 Кбайт, уровня 2 размером 512 Кбайт и без кэша уровня 3. Увеличение скорости измерялось по сравнению с последовательным вариантом (выполнение оптимизированного последовательного кода на двухпроцессорной системе с отключенной поддержкой гиперпоточности и отключенным через BIOS вторым процессором). Производительность многопоточного выполнения замерялась на двух конфигурациях: один процессор с включенной поддержкой гиперпоточности (SP+HT) и два процессора со включенной поддержкой гиперпоточности (DP+HT). В табл. 17.2 показано повышение скорости ОрепМР-версии AVSR-приложения с различными степенями вложенного параллелизма при разных конфигурациях системы. По сравнению с одним процессором с отключенной поддержкой гиперпоточности и одним процессором с включенной поддержкой гиперпоточности ускорение составляет от 1,18х до 1,28х при двух потоках на конфигурации SP+HT. сГ^Ъ с^Ъ
276 Глава 17 • Очередь заданий и другие сложные темы Ускорение составляет 1,57х при четырех внешних потоках, 1,99х — при четырех внешних и двух внутренних потоках и 1,85х — при четырех внешних и четырех внутренних потоках на конфигурации DP+HT. Таблица 17.2. Ускорение AVSR-приложения с тремя поточными схемами Схема Функциональная декомпозиция без вложенного параллелизма Вложенный параллелизм с двумя внутренними потоками Вложенный параллелизм с четырьмя внутренними потоками SP+HT 1,18х 1,28х 1,22х DP+HT 1,57х 1,99х 1,85х Эти значения ускорения показывают, что эксплуатация вложенного параллелизма дает примерно 40-процентное повышение производительности. В то же время добавочные потоки могут увеличить трафик памяти и внести дополнительные поточные издержки (это и является причиной того, что производительность при четырех внутренних потоках ниже, чем при двух). Поэтому эффективное управление параллелизмом является важным аспектом достижения желаемой производительности на системах с процессорами Intel Xeon с включенной поддержкой гиперпоточности (несмотря даже на то, что потенциально параллелизм может улучшить использование процессоров). Если бы вы задействовали для реализации параллелизма вызовы библиотеки Microsoft Windows Threading Library, вы также могли бы получить хорошую производительность, но многопоточная обработка при помощи низкоуровневых поточных пакетов требует гораздо больших усилий. При помощи компилятора C++ производства Intel и ОрепМР-поддержки времени выполнения вы можете получить такую же (или более высокую) производительность с гораздо меньшими усилиями. Решения, принятые как при проектировании, так и на стадии реализации OpenMP-приложения, могут серьезно сказаться на итоговой производительности. При возможности выбора между несколькими поточными схемами с одинаковой вычислительной сложностью следует выбирать ту, которая лучше всех поддается эффективной реализации параллелизма в вашем приложении. При выборе типа вложенного параллелизма старайтесь остановиться на таких вариантах разбиения данных (размере блока), политике планирования и количестве потоков, чтобы и внешняя, и внутренняя группы потоков могли расходовать ресурсы системы благоприятным для данной конфигурации образом. Так вы сможете получить максимально эффективный параллелизм, обеспечивающий наиболее высокую производительность вашего приложения. Многоуровневый параллелизм Эксплуатация комбинированного (на уровне потоков и векторов) параллелизма (то есть многоуровневого параллелизма) часто позволяет получить более высокую производительность. Использовать разные формы параллелизма (которые
Многоуровневый параллелизм 277 имеются в коде) для достижения высокой производительности не так сложно, как может показаться. Например, тест производительности STREAM1 может легко эксплуатировать параллелизм на уровне как потоков, так и векторов, демонстрируя таким образом выигрыш в производительности благодаря сочетанию параллелизма и векторизации. Этот очень известный тест производительности, написанный на стандартном языке Fortran (есть версия на С), измеряет производительность подсистемы памяти. В данном примере измеряется производительность четырех длинных векторных операций: □ сору — измеряет скорости передачи в отсутствие арифметических операций; □ seal e — добавляет простую арифметическую операцию; □ sum — добавляет третий операнд для того, чтобы можно было протестировать несколько портов загрузки/сохранения на векторных машинах; □ triad — разрешает сцепленные/наложенные/объединенные операции умножения/сложения. Почему тест производительности выбран в качестве примера? Это не означает, что в реальных приложениях нет многократного использования данных. Тест просто позволяет отвязать измерение производительности подсистемы памяти от гипотетической «пиковой» производительности системы. Рассмотрим фрагмент кода с операцией tri ad из программы STREAM в примере 17.9. Этот пример показывает, как можно эксплуатировать эти два уровня параллелизма (предполагается, что все эталоны доступа в векторном цикле выровнены по 16-байтной границе). Пример 17.9. Операция triad в тесте производительности STREAM t = second(dummy) b(l) - b(l) + t !$0MP PARALLEL DO !DIR$ VECTOR ALWAYS !DIR$ VECTOR NONTEMPORAL DO 60 j = l,n a(j) = b(j) + scalar*c(j) 60 CONTINUE t = second(dummy) - t Как показано в этом примере, для эксплуатации параллелизма на уровне и потоков, и векторов в последовательный код добавлены ключевые слова PARALLEL DO, VECTOR ALWAYS и VECTOR NONTEMPORAL. В табл. 17.3 показаны результаты выполнения программы STREAM для четырех вариантов: последовательного, а также Этот тест производительности написал Джон МакКаплин (John McCalpin) для измерения установившейся полосы пропускания памяти высокопроизводительных компьютеров. Детали см. на сайте www.cs.virginia^du/stream.
278 Глава 17 • Очередь заданий и другие сложные темы параллельного с одним, двумя и четырьмя потоками. Тест STREAM дополняет тест производительности LINPACK, который обычно оптимизирован до такой степени, что его быстродействие на современных компьютерах по большей части не зависит от производительности их подсистем памяти. Учтите, что при оценках пропускной способности памяти тест STREAM «дает очки» как за чтение, так и за запись в память. Таблица 17.3. Результаты измерения производительности операции triad Операция теста STREAM triad Последовательное выполнение 1714 Мбайт/с Параллельное выполнение с 1 потоком 1655 Мбайт/с Параллельное выполнение с 2 потоками 3200 Мбайт/с Параллельное выполнение с 4 потоками 5333 Мбайт/с Обратите внимание, что операция triad требует дополнительной операции чтения из памяти для загрузки элементов вектора а в кэш до того, как они перезапишутся. Производительность измеряется при стандартной настройке теста STREAM (векторы по два миллиона элементов, смещения массивов не заданы). В данном примере параллелизм проявляется на нескольких уровнях. Итерации цикла могут выполняться независимо (как было явно указано ОрепМР-прагмой) после генерации многопоточного кода компилятором. Еще один уровень параллелизма может быть использован для поточного цикла на векторном уровне путем распространения директив векторизации на поточный цикл. Поточный цикл имеет новые нижнюю и верхнюю границы (в соответствии с разделением цикла). В данном случае комбинация компилятора и библиотеки времени выполнения обеспечивает статическое четное разделение для каждого потока. На основе новых границ цикла после поточной обработки компиляторный блок векторизации генерирует SIMD- команды с целью выполнения операций с короткими векторами для поточного цикла. Динамическая очистка циклов позволяет компилятору продолжать векторизацию в тех ситуациях, когда нижняя и верхняя границы нового поточного цикла неизвестны в момент компиляции. В результате статически неопределенных границ циклов выравнивание ссылок памяти также не может быть определено во время компиляции. К счастью, компилятор производства Intel имеет в своем распоряжении средства, позволяющие обработать такие ситуации и избежать потерь производительности, которые обычно связаны с невыровненными обращениями к памяти (когда компилятор не может определить выравнивание статически). Эти усовершенствованные средства векторизации обсуждались в главе 13. За дополнительной информацией обращайтесь к руководству по программной векторизации [6]. Понятие привязки программных потоков При привязке программные потоки назначаются для выполнения на процессоры (или ядра). Потенциально производительность может снизиться, если поток переходит от процессора к процессору. Например, если потоки не выполняются
Понятие привязки программных потоков 279 на одном и том же процессоре, то может возникнуть проблема локальности кэша. При использовании компиляторов производства Intel вы очень скоро поймете, что схема с привязкой потоков к процессорам подходит вам лучше всего. ПРИМЕЧАНИЕ С целью упростить дальнейшее повествование, договоримся, что аббревиатура ЦПУ (центральное процессорное устройство) обозначает физический процессор (или ядро), а ЛПУ (логическое процессорное устройство) — логический процессор (когда включена поддержка гиперпоточности). Компилятор производства Intel и многопоточная библиотека времени выполнения поддерживают две схемы привязки и отображения потоков на процессоры или ядра при помощи переменной среды KMP_AFFINITY. Описание и примеры использования этой переменной показаны в табл. 17.4. Таблица 17.4. Переменная среды KMP_AFFII\inY KMP_AFFINITY physical physical,k logical logical,k [verbose,] [physical | logical] [,<starting CPU>] Привязывать последовательные потоки к разным физическим ЦПУ, начиная с ЦПУ 0 То же, что и в предыдущем случае, но начинать с ЦПУ к Привязывать последовательные потоки к разным физическим ЦПУ, начиная с ЦПУ 0, и отображать последовательные потоки по круговой схеме по номеру ЛПУ То же, что и в предыдущем случае, но начинать с ЦПУ к И для Windows, и для Linux создается карта ЛПУ-ЦПУ. Для этого поток отображается на каждый ЛПУ и определяется ЦПУ, на котором он находится. Отображение может быть сделано при помощи переменной среды KMP_AFFINITY, и каждый поток отображается своим внутренним идентификатором потока на ЦПУ, заданное следующей формулой: boundCPU = ( startingCPU +1 —ЩрТг I (mod (numCPU) Здесь: □ TID — внутренний идентификатор потока (Thread Identifier); □ boundCPU — ЦПУ, к которому привязан поток с идентификатором TID; □ startingCPU — начальное ЦПУ, используемое для привязки потоков; □ numLPU — количество ЛПУ на одно ЦПУ; □ numCPU — количество ЦПУ в системе. При физической привязке numLPU = 1. Если вы выполняете программу с восемью потоками на двухъядерной двухпроцессорной системе с поддержкой
280 Глава 17 • Очередь заданий и другие сложные темы гиперпоточности, то numLPU = 2. То есть у вас четыре ядра и восемь логических процессоров. В табл. 17.5 показаны четыре примера отображения потоков на ЛПУ и ЦПУ для разных значений KMP_AFFINITY. Таблица 17.5. Отображение потоков на ЦПУ и ЛПУ КМР AFFINITY physical,0 physical,2 logical,0 logical,2 CPUO <T0, T4> <T2, T6> (LPUO LPU1) <T1, T0> (LPU0LPU1) <T5, T4> CPU1 <T1,T5> <T3,T7> (LPU2 LPU3) <T2, T3> (LPU2 LPU3) <T6, T7> CPU2 <T2, T6> <T0,T4> (LPU4 LPU5) <T5,T4> (LPU4 LPU5) <T0,T1> CPU3 <T3, T7> <T1,T5> (LPU6 LPU7) <T6, T7> (LPU6 LPU7) <T2, T3> Внутри ядра потоки отображаются на ЛПУ по круговой схеме, причем начальное ЛПУ выбирается библиотекой времени выполнения. Например, поток ТО может быть отображен на ЛПУ1, а Т1 — на ЛПУО на ядре 0 при установке <logical,0>. Поток ТО может быть отображен на ЛПУО, a T1 — на ЛПУ 1 на ядре 2 при установке <logical,2>. Отсутствие привязки потоков не обязательно означает низкую производительность. Производительность больше зависит от того, каково общее число выполняющихся потоков и процессоров, а также от того, насколько хорошо потоки и данные взаимосвязаны и взаимозависимы. Тестирование приложения при помощи теста производительности является одним из способов убедиться, создает привязка потоков проблему для производительности или нет. Понятие планирования циклов Компилятор производства Intel и поточная библиотека времени выполнения поддерживают все четыре типа планирования циклов: static, dynamic, guided и runtime, определенные в спецификации ОрепМР. В варианте static блоки циклов обрабатываются по круговой схеме. В частности, в варианте static без указания размера блока каждый поток получает ровно один блок в порядке идентификаторов потоков. В варианте dynami с блоки обрабатываются по порядку поступления запросов, а размер блока по умолчанию равен 1. Каждый раз количество захватываемых итераций для каждого потока равно размеру блока, указанному в инструкции планирования (исключая последний блок). Например, если размер блока с помощью инструкции schedule(dynamic, 17) указан равным 17, а общее количество итераций составляет 100, то будет выполнено разбиение на 17, 17, 17, 17,17,15 — всего на шесть блоков.
Понятие планирования циклов 281 Для типа gui ded в спецификации ОрепМР версии 2.5 (ОрепМР 2005) описаны две схемы разбиения. Основная идея состоит в том, чтобы начать выполнение цикла путем разбиения итераций на блоки, размер которых начинается со следующего значения: [ со ] \ш\ Здесь: □ N — количество потоков; □ со — начальное (или общее) количество итераций. Это размер уменьшается до тех пор, пока все итерации не будут распланированы. Блоки также обрабатываются по порядку поступления запросов. Вот формула, которая выводится из рекуррентного соотношения: Здесь: □ N— количество потоков; □ кк — размер &-го блока, начиная с нулевого; □ Р^ — количество остающихся незапланированными итераций цикла при вычислении &-го блока. Когда пк становится слишком малым, значение усекается до размера S, указанного в инструкции schedule(guided, S). Размер блока по умолчанию равен 1 (если он не указан в инструкции schedule). Таким образом, для типа планирования guided способ разбиения цикла зависит от количества потоков (ЛГ), количества итераций (со) и размера блока (5). Например, при наличии цикла с параметрами со = 800, N = 2 и S = 100, цикл разбивается так: {200, 150, 114, 100, 100, 100, 36}. Когда значение к3 становится меньше 5, оно усекается до S. Когда количество остающихся незапланированными итераций меньше 5, то верхняя граница последнего блока при необходимости подрезается. Табл. 17.6 иллюстрирует различные варианты разбиения цикла при различных значениях NnS для планирования типа gui ded. Тип планирования gui ded, поддерживаемый компилятором производства Intel, совместим со спецификациией ОрепМР версии 2.5, которая несколько отличается от другой совместимой реализации, имеющейся в спецификации [25]. Таблица 17.6. Пример планирования цикла для типа guided Количество итераций цикла (со = 800) Количество потоков (N) 1 2 Размер блока (S) 100 100 Вариант разбиения цикла 400,200,100,100 200,150,114,100,100,100, 36 продолжение &
282 Глава 17 • Очередь заданий и другие сложные темы Таблица 17.6. (продолжение) Количество итераций цикла (со = 800) Количество потоков (N) 3 4 2 2 2 2 Размер блока (S) 100 100 50 100 150 200 Вариант разбиения цикла 134,111,100,100,100,100,100,55 100,100,100,100,100,100,100,100 200,150,113,85,63,50,50,50,40 200,150,114,100,100,100,36 200,150,150,150,150 200, 200,200, 200 В этой другой совместимой реализации разбиение цикла выполняется по следующему рекуррентному соотношению: Здесь отсутствует дополнительный делитель 2. При использовании этой формулы тот же самый цикл с параметрами со = 800, N=2nS= 100 разбивается так: {400,200,100,100}. Применение дополнительного делителя 2 приводит к более мелкомодульному параллелизму, который может дать лучшее распределение нагрузки в приложениях, поскольку издержки на реализацию параллелизма в архитектурах Intel весьма незначительны. Планирование типа runtime не является планированием как таковым. В этом варианте тип планирования определяется через переменную среды 0MP_SCHEDULE, которая по умолчанию установлена в static Понимание схем разбиения/планирования циклов поможет вам выбрать такую схему планирования, которая приведет к хорошей балансировке нагрузки и поможет избежать ложного распределения в приложениях на этапе выполнения. Пример 17.10. Пример кода сложным распределением float А[1000], В[1000]: #pragma omp parallel for schedule(dynamic, 8) for (k=0; k<1000; k++) A[k] = A[k] + exp(k) * B[k] Предположим, у нас есть двухъядерная система, а размер строки кэша равен 64 байта, тогда для OpenMP-кода из примера 17.10 два блока (две части массива) могут оказаться в одной строке кэша, поскольку размер блока установлен в 8 в инструкции schedule (предполагая что базовый адрес массива А выровнен по строке кэша, каждый блок массива А занимает 32 байт в строке кэша, а это означает,
Основные моменты 283 что два блока размещаются в одной и той же строке кэша). Поскольку два блока могут читаться и писаться из двух потоков одновременно, подобное размещение приводит к тому, что строка кэша часто признается недействительной (хотя эти два потока читают/пишут не один и тот же блок). Такая ситуация называется ложным распределением, поскольку на деле распределять одну строку кэша между двумя потоками нет необходимости. Простым способом решения проблемы является использование инструкции schedule (dynamic ,16), тогда один блок полностью займет всю строку кэша, и ложное распределение будет исключено. Подводя итог сказанному, можно отметить, что выбор правильной схемы разбиения цикла и ликвидация ложного распределения за счет выбора размера блока в соответствии с размером строки кэша могут значительно повысить производительность вашего приложения. Основные моменты Очередь заданий является OpenMP-расширением, позволяющим применить параллельное программирование к программам с динамическими структурами данных и сложными управляющими структурами. При использовании модели очереди заданий и других нетривиальных приемов помните о следующем: □ Ознакомьтесь со всеми OpenMP-конструкциями, конструкциями очереди заданий, моделью памяти, а также параметрами компилятора, имеющими отношение к ОрепМР и очереди заданий. □ При возможности выбора между несколькими алгоритмами с одинаковой вычислительной сложностью, выбирайте тот, который лучше всех адаптируется для параллельного выполнения при помощи OpenMP-конструкций и конструкций очереди заданий. □ При проектировании структур данных располагайте данные так, чтобы для наиболее часто выполняемых вычислений обращения к памяти шли благоприятным для потоков и векторов образом (для эффективного многоуровневого параллелизма). □ Выбирайте тип планирования и значение размера блока так, чтобы исключить ложное распределение, снижайте синхронизацию и конкуренцию за кэш при выполнении OpenMP-конструкций, поддерживающих распределение работ. □ Используйте профилировщик потоков (подключаемый модуль Intel Thread Profiler для анализатора производительности VTune производства Intel), чтобы понять и настроить производительность приложения. Анализатор VTune обеспечивает анализ критического пути, что помогает ликвидировать узкие места в последовательном коде, кроме того, в нем имеется временная шкала, иллюстрирующая синхронизацию между потоками (позволяет понять порядок следования событий в реальном времени и оценить их продолжительность).
Разработка и оптимизация приложений
Конкретный пример поточной обработки в видеокодеке 6)1ТООБС|£бЧ BENMNTnO i Эксплуатация параллелизма на уровне потоков является весьма привлекательным подходом для повышения производительности мультимедийных приложений, выполняющихся на многопоточных процессорах общего назначения. С учетом появления новых двухъядерных и многоядерных процессоров, чем раньше вы начнете разрабатывать приложения с расчетом на многопоточность, тем лучше. В предыдущих главах упоминались ключевые элементы спецификации ОрепМР и на примере нескольких небольших приложений описывались эффективные варианты применения ОрепМР с целью эксплуатации параллелизма на уровне потоков. В данной главе рассказывается о разработке более крупного проекта, нацеленного на достижение высокой производительности. В этом примере мы займемся разработкой и реализацией многопоточного кодировщика Н.264. Параллелизм в кодировщике Н.264 достигается с помощью программной модели ОрепМР. Это приложение является примером применения передовых компиляторных технологий, реализованных в компиляторе C++ производства Intel, к архитектурам процессоров Intel, поддерживающих технологию гиперпоточности. Вместе взятые, эти два эффективных метода многопоточного разбиения данных помогают повысить производительность такого большого и сложного приложения, как многопоточный кодировщик Н.264. Кроме того, данный пример показывает как использовать различные возможности OpenMP-программирования. Одна реализация (в которой применена модель очереди заданий) немного медленнее, зато программа легче читается. Другой подход ориентирован на максимальную скорость. В результате на системе с четырьмя процессорами Intel Xeon и включенной поддержкой гиперпоточности были достигнуты показатели, в 3,97-4,69 раз превышающие по быстродействию хорошо оптимизированный последовательный код. 18
Реализация параллелизма в кодировщике Н.264 287 Исходная производительность кодировщика Н.264 Н.264 (ISO/IEC 2002) — это развивающийся стандарт видеокодирования, который был предложен группой JVT (Joint Video Team). Новый стандарт нацелен на высококачественное кодирование видеосодержимого при очень низких значениях битовой скорости. В Н.264 используется та же самая модель кодирования с преобразованием, что и существующие стандарты, такие как Н.263 и MPEG-4 (ISO/ IEC 1998). Кроме того, некоторые функциональные возможности Н.264 повышают производительность кода. Поскольку стандарт становится все более сложным, процесс кодирования требует гораздо большей вычислительной мощности, чем большинство существующих стандартов. Следовательно, требуются какие-то механизмы, повышающие быстродействие кодировщика. Один из способов повысить быстродействие приложения — обрабатывать задания параллельно. В [40] было показано, что использование технологии ММХ/ SSE/SSE2 повышает производительность кодировщика Н.264 от двух до четырех раз. Компания Intel применила к кодировщику Н.264 тот же подход, и лишь за счет SIMD-оптимизации получила результаты, приведенные в табл. 18.1. Таблица 18.1. Ускорение основных модулей кодировщика Н.264 за счет SIMD-оптимизации Модуль SAD-вычисления Преобразование Адамара Поиск субпикселов Преобразование и дискретизация целых Четверть-пиксельная интерполяция Ускорение 3,5х 1,6х 1,3х 1,3х 2,0х Хотя кодировщик после SIMD-оптимизации стал в два-три раза быстрее, для обработки видео в реальном времени его быстродействия оказалось недостаточно. Кроме того, оптимизированный последовательный код не может использовать преимущества распределения нагрузки, доступные многопроцессорным системам и системам с поддержкой гиперпоточности — этим двум ключевым в плане роста производительности архитектурам процессоров производства Intel. Другими словами, за счет параллелизма на уровне потоков вы можете значительно повысить производительность кодировщика Н.264. Реализация параллелизма в кодировщике Н.264 Эксплуатируя параллелизм на уровне потоков, вы получаете потенциальную возможность повышения производительности. Чтобы добиться максимального
288 Глава 18 • Конкретный пример поточной обработки в видеокодеке увеличения быстродействия по сравнению с хорошо отлаженным последовательным кодом на процессоре с поддержкой гиперпоточности, при переделке кодировщика Н.264 в параллельный вариант вы должны иметь в виду следующее: □ критерий выбора варианта разбиения (по данным или по заданиям); □ решения по степени детализации потоков; □ как первая реализация использует две очереди слоев; □ как вторая реализация использует одну очередь заданий. Декомпозиция по заданиям и по данным Вы можете разделить процесс кодирования в Н.264 на несколько программных потоков, используя либо функциональную декомпозицию, либо декомпозицию по данным. □ Функциональная декомпозиция. Каждый кадр проходит несколько функциональных этапов: оценка движения, компенсация движения, интегральное преобразование, дискретизация и статистическое кодирование. Для опорных кадров необходимы также обратная оценка, обратное интегральное преобразование и фильтрация. Эти функции можно исследовать в отношении возможности реализовать в них параллелизм. □ Декомпозиция по данным. Как показано на рис. 18.1, кодировщик Н.264 обрабатывает видеопоследовательность как множество групп изображений (Group Of Pictures, GOP). Каждая GOP-группа состоит из определенного количества кадров. Каждый кадр разделен на слои. Каждый слой является единицей кодирования и не зависит от других слоев того же кадра. Слой может быть подвергнут дальнейшей декомпозиции на макроблоки, которые являются единицей оценки движения и статистического кодирования. Наконец, макроблок может быть разделен на блоки и субблоки. Все это — потенциальные места для реализации в кодировщике параллелизма. Кадры в последовательности Слои в кадре Макроблоки в слое Блоки в макроблоке Рис. 18.1. Иерархия декомпозиции по данным в кодировщике Н.264
Реализация параллелизма в кодировщике Н.264 289 Для выбора оптимальной схемы разбиения (по заданиям или по данным) сравним преимущества и недостатки обеих схем: □ Масштабируемость. При декомпозиции по данным для увеличения количества потоков вы можете уменьшить размер обрабатываемого каждым потоком модуля. Вследствие иерархической структуры (GOP-группы — кадры — слои — макроблоки — блоки) у вас есть много вариантов выбора размера обрабатываемого модуля (что позволяет достичь хорошей масштабируемости). При функциональной декомпозиции потоки имеют разные функции. Для увеличения количества потоков разбивайте функцию на два или более потоков, пока она не окажется неделимой. □ Балансировка нагрузки. При декомпозиции по данным каждый поток выполняет одну и ту же операцию, только для разных блоков данных (которые имеют один и тот же размер). Теоретически, при отсутствии промахов кэша и прочих неконтролируемых факторов все потоки должны иметь одинаковое время выполнения. В то же время достичь хорошей балансировки нагрузки между функциями трудно, поскольку время выполнения каждой функции определяется избранным алгоритмом. Более того, любая попытка функциональной декомпозиции видеокодировщика (с целью хорошей балансировки нагрузки) также зависит от алгоритмов. По мере совершенствования стандарта алгоритмы, несомненно, постепенно изменятся, чтобы эксплуатировать параллелизм уровня потоков уже на нескольких уровнях, добиваясь хорошей балансировки нагрузки. Рассмотрев все эти факторы, вы должны были выбрать декомпозицию по данным в качестве многопоточной схемы. Подробности описываются в двух следующих подразделах. Параллелизм на уровне слоев После того как вы выбрали схему декомпозиции, на следующем шаге требуется определить степень детализации каждого потока. Одна из возможных схем декомпозиции по данным — разбиение кадра на небольшие слои. Реализация параллелизма по слоям имеет свои достоинства и недостатки. Достоинством является независимость слоев кадра. Поскольку слои независимы, вы можете одновременно кодировать все слои в любом порядке. Недостатком является итоговое увеличение битовой скорости. На рис. 18.2 показана зависимость искажений кодировщика от битовой скорости при разбиении кадра на разное количество слоев. Когда кадр разбивается на девять слоев, то для удержания качества на прежнем уровне приходится увеличивать битовую скорость на 15-20 %, поскольку слои нарушают зависимости между макроблоками. Эффективность сжатия уменьшается тогда, когда макроблок в одном слое не может использовать для сжатия макроблок в другом слое. Во избежание увеличения битовой скорости (для сохранения прежнего качества) необходимо задействовать другие возможности реализации параллелизма в видеокодировщике.
290 Глава 18 • Конкретный пример поточной обработки в видеокодеке Качество в зависимости от количества слоев -1 слой • ■ 4 слоя 9 слоев 400 Битовая скорость (Кбит/с) Рис. 18.2. Качество кодирования изображения в зависимости от количества слоев в изображении Параллелизм на уровне кадров Другая возможная схема эксплуатации параллелизма — определить независимые кадры. Обычно вы кодируете последовательность кадров с помощью структуры IBBPBBP...1 В каждой последовательности у вас есть два В-кадра между Р-кадрами. Р-кадры являются опорными, от них зависят другие Р- или В-кадры (а В-кадры такими не являются). Зависимость между кадрами показана на рис. 18.3. В этой структуре РВВ-кодирования завершение кодирования конкретного Р-кадра означает готовность к кодированию последующего Р-кадра и двух его В-кадров. Чем больше кадров кодируется одновременно, тем большую степень параллелизма вы можете эксплуатировать. Поэтому Р-кадры являются критической точкой кодировщика. Ускорение кодирования Р-кадров увеличивает количество готовых для кодирования кадров и устраняет возможность простоя потоков. Кодируйте в вашей реализации сначала I- или Р-кадры, а затем — В-кадры. В отличие от разбиения кадра на слои, использование параллелизма между кадрами не увеличивает битовую скорость. Однако зависимость между ними ограничивает масштабируемость потоков. Компромисс здесь — сочетание обоих подходов (декомпозиции по данным и функциональной декомпозиции) в одной реализации. I-кадрами в видеокодеках называют ключевые (опорные) кадры, которые могут кодироваться или декодироваться независимо. Обычно на 15-60 кадров имеется один I-кадр. Р-кадр обозначает прогнозируемый кадр, который прогнозируется из ранее закодированного I- или Р-кадра. Поскольку Р-кадр прогнозируется из ранее закодированного кадра, эта зависимость осложняет одновременное кодирование двух Р-кадров. В-кадр обозначает двунаправленный прогнозируемый кадр, который прогнозируется из двух ранее закодированных I- или Р-кадров. Никакие кадры от В-кадров не зависят.
Реализация параллелизма в кодировщике Н.264 291 В первую очередь старайтесь эксплуатировать параллелизм между кадрами, чтобы получить рост производительности без увеличения битовой скорости. В некоторой точке вы достигнете верхнего предела количества потоков, к которым вы можете применить параллелизм на уровне кадров. Когда это произойдет, проанализируйте параллелизм между слоями. В конечном итоге ваше приложение будет максимально использовать ресурсы процессора, обеспечивая максимальный уровень сжатия и минимальную битовую скорость. [Щ [—►[ 3(Р)]- т*\ 6(Р) [ Ы 1<в) | L*| 2(B) | T*fgg> [ Ы 4(B) | Ц 5(B) | y»|l2(P)| Ы 7(B) U{3(B) Номера кадров проставлены по порядку видеокадров Рис. 18.3. Зависимости по данным между кадрами Реализация с использованием двух очередей слоев Кодировщик Н.264 делится на три части, обеспечивающие предварительную обработку вводимых данных, кодирование и заключительную обработку выводимых данных. В ходе предварительной обработки вводимых данных читаются несжатые изображения, выполняются некоторые предварительные процедуры, после чего изображения поступают к кодирующим потокам. Подвергнутые предварительной обработке изображения помещаются в буфер, называемый буфером изображений. При обработке выводимых данных проверяется статус кодирования каждого кадра, и закодированный результат последовательно направляется в выходной поток. После этого элементы буфера изображений повторно используются для подготовки изображений к кодированию. Хотя процессы ввода и вывода кодировщика должны быть последовательными, вычислительная сложность этих процессов незначительна по сравнению с процессом кодирования. Поэтому вы можете использовать для обработки процессов ввода и вывода единственный поток. Этот поток становится главным потоком, который обеспечивает проверку всех зависимостей по данным. Для применения параллелизма между слоями необходимо использовать еще один буфер, называемый буфером слоев. После предварительной обработки каждого изображения его слои помещаются в буфер слоев. Слои, помещенные в буфер слоев, являются независимыми и готовы к кодированию; готовность опорных кадров проверяется в процессе ввода. В данном случае вы можете кодировать эти слои не по порядку. Для того чтобы реализовать разные приоритеты слоев из В-кадров, а также слоев из I- и Р-кадров, используйте две различные очереди слоев для их обработки. Псевдокод в примере 18.1 реализует эту модель.
292 Глава 18 • Конкретный пример поточной обработки в видеокодеке Пример 18.1. Модель очередей слоев для кодировщика Н.264 // Псевдокод многопоточного кодировщика Н.264. // использующего модель очередей слоев omp_set_nested( # of encoding thread + 1) #pragma omp parallel sections { #pragma omp section { while ( there is frame to encode ) { if ( there is free entry in image buffer ) issue new frame to image buffer else if ( there are frame encoded in image buffer) commit the encoded frame, release the entry else // здесь обрабатывается зависимость wait; } } #pragma omp section { Ipragma omp parallel num_threads(# of encoding thread) { while ( 1 ) { if ( there is slice in slice queue 0) // высший приоритет для I- и Р-кадров Encode one slice else if ( there is slice in slice queue 1) // низший приоритет для В-кадров encode one slice else if ( all frames are encoded ) exit; else // ждем главный поток, чтобы добавить еще слои wait } } } }
Реализация параллелизма в кодировщике Н.264 293 На рис. 18.4 показано, как видеопоток обрабатывается многопоточной реализацией параллельного кодировщика Н.264. В коде один поток обрабатывает и ввод, и вывод (поочередно), а другие потоки кодируют слои (не поочередно). 1(0) N/A N/A Р(3) В(1) В(2) Р(6) В(4) В(5) Буфер изображения Поток 0 Входной файл Выходной файл Очередь слоев 0 (I и Р-кадры) Очередь слоев 1 (В-кадры) Поток 1 Поток 2 Поток 3 Поток 4 Рис. 18.4. Реализация с использованием буферов изображений и слоев Реализация с использованием очереди заданий В реализации их примера 18.1 используются OpenMP-прагмы, что очень отличает структуру параллельного кода от последовательного. Во второй предлагаемой реализации применяется модель очереди заданий, которая поддерживается компилятором C++ производства Intel [34,36]. По существу, для каждой программы с конструкциями очередей заданий библиотекой времени выполнения создается группа потоков (в тот момент, когда главный поток встречает параллельную область). На рис. 18.5 показана модель очереди заданий. Планировщик потоков времени выполнения выбирает для запуска один поток (Тк) из всех потоков, которые встречают прагму taskq. Все остальные потоки ждут, пока задание не будет помещено в очередь работ. Концептуально, прагма taskq инициирует следующую последовательность действий: 1. Вызывает создание пустой очереди выбранным потоком Тк. 2. Ставит в очередь каждое встреченное задание. 3. Выполняет код внутри блока taskq как один поток.
294 Глава 18 • Конкретный пример поточной обработки в видеокодеке Поставить в очередь задание q Поставить в очередь задание Пул потоков Т1 Т2 тк ... tn JT+ А Т(ш - 1... N, и m i К) Рабочая очередь Убрать из очереди задание (единицу работы) "Ж £ Рабочая очередь пуста Запланировать задание (единицу работы) Завершение Рис. 18.5. Модель очереди заданий Прагма task указывает единицу работы, которую потенциально может выполнить другой поток. Когда прагма task лексически встречается внутри блока taskq, то код внутри блока task помещается в очередь, связанную с прагмой taskq. Эта воображаемая очередь распускается тогда, когда заканчивается вся поставленная в нее работа и достигается конец блока taskq. В первой предлагаемой многопоточной схеме кодировщика Н.264 используются два буфера FIFO: буфер изображений и буфер слоев. Главный поток заведует тремя процессами: 1. Перемещение необработанных изображений в буфер изображений (когда там есть место). 2. Перемещение слоев буфера изображений в буферы слоев (когда в буфере слоев есть место, а изображение еще не отправлено). 3. Перемещение закодированных изображений из буфера изображений (когда изображение закодировано). Рабочие потоки отвечают за кодирование новых слоев, когда слой ждет кодирования в буфере слоев. Все эти операции синхронизированы посредством буфера изображений. Поэтому очень естественно использовать модель очереди заданий, поддерживаемую компилятором производства Intel. Фрагмент в примере 18.2 представляет собой псевдокод поточной обработки с применением модели очереди заданий для кодировщика Н.264. Этот многопоточный исходный код по виду ближе к однопоточному. Единственная разница — это прагма, являющаяся отличительной чертой ОрепМР. Более того, в данной
Производительность 295 схеме больше нет управляющего потока, а только некоторое количество рабочих потоков. Пример 18.2. Модель очереди заданий для многопоточной обработки в кодировщике Н.264 // Псевдокод многопоточного кодировщика Н.264, // использующего модель очереди заданий #pragma Intel omp parallel taskq { while ( there is frame to encode ) { if ( there is no free entry in image buffer ) (1) commit the encoded frame; (2) release the entry; (3) load the original picture to memory; (4) prepare for encoding; for (all slice in this frame) { #pragma intel omp task { encode one slice; i } } } Производительность Измерения производительности многопоточного кодировщика проводились в ходе экспериментов на следующих системах: □ Компьютер Dell Precision 530 на двух процессорах Intel Xeon (четыре логических процессора) с поддержкой гиперпоточности, тактовая частота 2,0 ГГц, кэш уровня 2 размером 512 Кбайт, 1 Гбайт оперативной памяти. □ Компьютер IBM eServer xSeries 360 на четырех процессорах Intel Xeon (восемь логических процессоров) с поддержкой гиперпоточности, тактовая частота 1,5 ГГц, кэш уровня 2 размером 256 Кбайт, кэш уровня 3 размером 512 Кбайт, 2 Гбайт оперативной памяти. Если не указано иное, разрешение входного видеосигнала равнялось 352 х 288 пикселов (или 22 х 18 макроблоков). Для того чтобы обеспечить достаточное количество слоев для восьми потоков, за базовую единицу кодирования для каждого потока программа принимает слой.
296 Глава 18 • Конкретный пример поточной обработки в видеокодеке Компромисс между повышенной скоростью и эффективным сжатием Кадр может быть разбит максимум на 18 слоев. Если принять слой за базовую единицу кодирования, это может снизить издержки синхронизации, поскольку между слоями внутри кадра в процессе кодирования не существует зависимостей по данным. Как уже упоминалось ранее, разбиение кадра на несколько слоев может увеличить степень параллелизма, но также увеличивает и битовую скорость. Основными задачами являются достижение повышенной скорости выполнения и снижение битовой скорости без какой бы то ни было потери качества. Поэтому при разбиении на слои следует тщательно выбирать пороговое значение. На рис. 18.6 и 18.7 показаны разные сочетания скорости кодирования и соответствующей битовой скорости в зависимости от количества слоев в кадре. На рис. 18.6 количество слоев изменяется от 1 до 18 при сохранении постоянного качества кодированных кадров. Скорость кодирования растет, когда количество слоев в кадре изменяется с 1 до 2 (для системы DELL 530), а затем остается практически постоянной при изменении количества слоев с 3 до 18. В то же время прирост битовой скорости небольшой при количестве слоев меньше 3, но начинает расти при увеличении количества слоев с 3 до 18. Отсюда следует важное заключение — компромисс достигается при разбиении кадра на 2 или 3 слоя, при этом достигается максимальное ускорение и минимальная битовая скорость. Ускорение для двух процессоров без гиперпоточности —X— Битрейт i ф Q. О 2 3 6 9 Количество слоев Рис. 18.6. Зависимости ускорения кодирования и битовой скорости от количества слоев в кадре (без поддержки гиперпоточности) На рис. 18.7 показано, что для того, чтобы загрузить восемь логических процессоров компьютера IBM хЗбО, необходимо использовать более трех слоев на кадр. По сути, нам необходимо девять потоков для достижения оптимального уровня
Производительность 297 производительности четырех физических процессоров с поддержкой гиперпоточности. Мы хотим поддерживать количество слоев примерно равным количеству логических процессоров. Этот простой подход дает высокую производительность. При выборе достаточного количества слоев для поддержания загрузки потоков при кодировании вы можете добиться хорошего качества изображения и компромиссного значения битовой скорости. Ускорение для четырех процессоров с поддержкой гиперпоточности —X — Битовая скорость 5.00 4.00 | 3.00 О- 8 £ 2.00 1.00 1.60 1 2 3 6 9 18 Количество слоев о о о. о ш Рис. 18.7. Зависимости ускорения кодирования и битовой скорости от количества слоев в кадре (с поддержкой гиперпоточности) Производительность на многопроцессорных системах с поддержкой гиперпоточности В табл. 18.2 показаны ускорения для многопоточного кодировщика на четы- рехпроцессорной системе IBM хЗбО с поддержкой гиперпоточности. В данной реализации кадр разбивался на девять слоев. В целом многопоточный кодировщик Н.264 обеспечил увеличение скорости выполнения в следующих пределах: от 1,9 до 2,01 раз на двухпроцессорной системе, от 3,61 до 3,99 раз на четырехпроцессор- ной системе и от 3,97 до 4,69 раз на четырехпроцессорной системе с поддержкой гиперпоточности (для пяти различных входных видеосюжетов). Таблица 18.2. Ускорения для различных видеосюжетов при использовании двух очередей слоев (система IBM хЗбО) 720 х 480 Париж 2 процессора 1,94х 1,98х 4 процессора без поддержки гиперпоточности 3,69х 3,94х 4 процессора с поддержкой гиперпоточности 4,31х J 4,61х J продолжение &
298 Глава 18 • Конкретный пример поточной обработки в видеокодеке Таблица 18.2. (продолжение) I Новости Мобайл Стефан 2 процессора 2,01х 1,97х 1,97х 4 процессора без поддержки гиперпоточности 3,99х 3,94х 3,94х 4 процессора с поддержкой гиперпоточности 4,63х 4,68х 4,69х Вы можете увидеть некоторые различия в производительности между первой реализацией с двумя очередями слоев и второй реализацией с одной очередью заданий (табл. 18.3). Различие в производительности больше тогда, когда в системе больше процессоров. Поскольку в реализации используются две очереди для ускорения кодирования I- и Р-кадров, то она может подготовить для кодирования большее количество слоев, особенно когда для выполнения работы имеется большое количество процессоров. В то же время модель очереди заданий в ОрепМР поддерживает только одну очередь. В этом случае все слои обрабатываются одинаково. Поэтому программные потоки проводят больше времени в состоянии простоя, когда в системе имеется много процессоров. Таблица 18.3. Ускорения для различных видеосюжетов при использовании одной очереди заданий (система IBM хЗбО) Бригадир Новости Мобайл Стефан 2 процессора 1,90х 1,91х 1,92х 1,93х 4 процессора без поддержки гиперпоточности 3,61х 3,61х 3,67х 3,68х 4 процессора с поддержкой гиперпоточности 3,97х 3,98х 4,15х 4,12х При включении гиперпоточности программа ускоряется в 1,2 раза. Объяснение этого факта лежит в метриках микроархитектуры (см. следующий раздел). Исследование производительности В табл. 18.4 показано распределение количества команд, удаляемых за такт в двухпроцессорной системе Dell Precision 530 при отключенном втором процессоре. Несмотря на то, что в течение почти половины времени выполнения команды вообще не удаляются, вероятность удаления большего количества команд выше при поддержке гиперпоточности. Эта статистика означает, что при поддержке гиперпоточности достигается более высокий коэффициент использования процессора.
Исследование производительности 299 Таблица 18.4. Распределение процента удаляемых за такт команд (получено при помощи анализатора VTune) Удалена 1 команда Удалены 2 команды Удалены 3 команды С поддержкой гиперпоточностью 20,03 % 16,52 % 7,79% Без поддержки гиперпоточности 25,67 % 18,62 % 8,55% В табл. 18.5 и 18.6 показаны объединенные результаты. При отключенной поддержке гиперпоточности кэш трасс проводит около 80 % времени и режиме доставки, что хорошо для производительности, и примерно 18 % времени в режиме сборки, что плохо для производительности. Однако при включенной поддержке гиперпоточности время в режиме доставки сокращается до 70 %, в то время как в режиме сборки возрастает до 25 %. Это падение производительности означает, что препроцессор системы с поддержкой гиперпоточности не может обеспечить исполнительное устройство достаточным количеством микроопераций. Аналогично увеличивается и количество промахов загрузок кэша уровня 1. Вы видите 50-процентное увеличение количества промахов кэша уровня 1 при включенной поддержке гиперпоточности. Это увеличение количества промахов с 6 до 9 % является следствием того, что два логических процессора в одном физическом корпусе совместно используют кэш уровня 1 размером всего 8 Кбайт. Короче говоря, прирост производительности за счет гиперпоточности для нашего многопоточного кодировщика Н.264 ограничен возможностями кэша трасс и кэша уровня 1. Таблица 18.5. Характеристики микроархитектуры системы Dell Precision 530 Команд за такт Микроопераций за такт Режим доставки кэша трасс Режим сборки кэша трасс Количество промахов загрузок кэша уровня 1 1 процессор без поддержки гиперпоточности 0,79 1,11 80,80 % 17,59 % 6,24% 1 процессор с поддержкой гиперпоточности 0,90 1,26 71,13% 25,15% 9,19% 2 процессора без поддержки гиперпоточности 1,57 2,17 80,39 % 17,27 % 6,42% 2 процессора с поддержкой гиперпоточности 1,81 2,48 69,06 % 25,42 % 9,02% продолжение &
300 Глава 18 • Конкретный пример поточной обработки в видеокодеке Таблица 18.5. (продолжение) Количество промахов загрузок кэша уровня 2 Коэффициент использования системной шины 1 процессор без поддержки гиперпоточности 0,45% 0,65% 1 процессор с поддержкой гиперпоточности 0,56% 1,51% 2 процессора без поддержки гиперпоточности 0,54% 1,57% 2 процессора с поддержкой гиперпоточности 0,54% 3,74% Таблица 18.6. Характеристики микроархитектуры системы IBM хЗбО Команд за такт Микроопераций за такт Режим доставки кэша трасс Режим сборки кэша трасс Количество промахов загрузок кэша уровня 1 Количество промахов загрузок кэша уровня 1 Коэффициент использования системной шины 1 процессор без поддержки гиперпоточности 0,77 1,085 80,60 % 17,96 % 6,89% 0,74% 0,61% 1 процессор с поддержкой гиперпоточности 1,55 12,156 79,68 % 18,12% 6,43% 0,77 % 1,58% 2 процессора без поддержки гиперпоточности 3,14 4,261 77,45 % 18,42 % 6,29% 0,73% 3,78% 2 процессора с поддержкой гиперпоточности 3,64 4,743 67,50 % 26,20 % 9,13% 0,82% 8,13% Коэффициент использования системной шины — единственная характеристика микроархитектуры при многопроцессорной конфигурации, которая существенно изменяется. Количество шинных операций не претерпевает существенного увеличения по мере роста количества потоков. Время выполнения снижается благо-
Исследование производительности 301 даря лучшему использованию процессорных ресурсов, что достигается за счет достаточного уровня параллелизма потоков. Результатом является повышенный коэффициент использования системной шины. В табл. 18.3 показано также, что в случае меньшего количества слоев время выполнения на четырехпроцессорной системе с поддержкой гиперпоточности даже больше, чем на четырехпроцессорной системе. Это увеличение можно объяснить профилем потоков. На рис. 18.8 показан профиль для случая, когда кадр содержит только один слой. Вследствие низкого уровня параллелизма кодирующий поток проводит в состоянии ожидания около 61,8 % времени выполнения. Поток 0: Поток 1: Поток 2: Поток 3: Поток 4: Поток 5: Поток 6: Поток 7: Поток 8: Поток выполняется Ц Поток ждет буфера Рис. 18.8. Профиль времени выполнения для двух очередей слоев при одном слое в кадре, полученный при помощи программы Intel® Thread Profiler На рис. 18.9 показан профиль при 18 слоях в кадре. Восемь кодирующих потоков постоянно заняты (кроме времени настройки). Модель с восемью кодирующими потоками проводит в состоянии ожидания только 1,4 % времени выполнения. В данном случае все процессорные ресурсы полностью заняты. Поток 0: Поток 1: Q Поток 2: [[ Поток 3: Щ^ Поток 4: HJL Поток 5: Г Поток 6: ШС Поток 7: ПОТ Поток 8: II I Поток выполняется []] Поток ждет буфера Поток ждет другого слоя Рис. 18.9. Профиль времени выполнения для двух очередей слоев при 18 слоях в кадре, полученный при помощи программы Intel® Thread Profiler
302 Глава 18 • Конкретный пример поточной обработки в видеокодеке Поэтому при поиске компромисса вам следует тщательно выбирать наилучший способ балансировки слоев в кадре. Необходимо стремиться сократить количество слоев и в то же время оставить достаточно слоев, чтобы все кодирующие потоки были заняты. Если количество слоев меньше, чем количество потоков, то скорость выполнения снижается. На рис. 18.10 показан профиль времени выполнения второй реализации (с использованием одной очереди заданий). Как уже отмечалось, все слои обрабатываются одинаково, поскольку OpenMP-модель очереди заданий поддерживает только одну очередь. Поэтому в системе может быть слишком мало готовых к кодированию слоев, как можно видеть по времени простоя потоков. По сравнению с рис. 18.9, на рис. 18.10 видно, что процессоры используются менее эффективно. Поток 2: ^М^^^^^^|Щ^|^^^^ШтШ1^^^^^^^1 Поток 5: 1Г т^^^Ш^Ш№1Ш^»^^^^^^^^^^^^М Поток выполняется Ш Поток ждет буфера Щ Рис. 18.10. Профиль времени выполнения для одной очереди заданий, полученный при помощи программы Intel® Thread Profiler Издержки многопоточности Таким образом, когда количество потоков равно количеству логических процессоров, получается наилучший баланс ускорения и параллелизма. Но что же происходит с производительностью, когда количество потоков больше (или меньше) количества логических процессоров? На рис. 18.11 показано, что ускорение изменяется в зависимости от количества потоков (для реализации с двумя очередями слоев). При увеличении количества потоков скорость растет, достигая максимума, когда количество потоков равняется количеству логических процессоров. Интересное наблюдение: ускорение по существу постоянно либо незначительно падает тогда, когда количество потоков больше, чем количество логических процессоров. Следовательно, издержки поточной обработки минимальны. Другими словами, многопоточный код, сгенерированный компилятором, эксплуатирует параллелизм эффективно, и издержки многопоточной библиотеки времени выполнения малы. Более того, многопоточный кодировщик Н.264 должен хорошо масштабироваться на многопроцессорных системах среднего класса (таких как показанная на рис. 18.12), поскольку производительность не зависит от количества потоков.
Дальнейшая настройка производительности 303 0.5 0.0 -I ■»-•■+■»»♦♦ -А-~А-'А А А А А А А А А А ■*■ А а| xxxxxxxxxxxxxxxl DR-HT ■DP кг- ■UP I t I I oo о см + v- т- T- + + Потоки + CO 7 Рис. 18.11. Ускорение в зависимости от количества потоков на двухпроцессорной системе Dell ■QP+HT -QP ~~a^~DP ■UP #■ » А-. А А А'- А А 'А А А' А А' А А А I X X )( К X X X X X К К X X И К К Х| -н^нн~н 4-Н—Ь Потоки Рис. 18.12. Ускорение в зависимости от количества потоков на четырехпроцессорной системе IBM хЗбО Дальнейшая настройка производительности В данной главе, в которой описывается первая параллельная реализация кодировщика Н.264 для многопоточной архитектуры, вы узнали о различных компромиссах, касающихся качества видеосигнала и уровня параллелизма. В других исследованиях использовался наиболее прямолинейный подход к кодированию видеосигнала — либо по кадрам, либо по слоям [9,30]. Наш подход несколько более комплексный, поскольку в нем эксплуатируется параллелизм на уровне как слоев, так и кадров. Вы можете найти еще несколько источников информации по эксплуатации параллелизма в MPEG-кодировщиках [9].
304 Глава 18 • Конкретный пример поточной обработки в видеокодеке Даже тогда, когда необходимая производительность достигнута, можно всегда найти пути для улучшения. В данном случае вы можете проанализировать влияние на производительность различных вариантов разрешения изображения. Хотя разрешение исходного изображения может изменяться от QCIF, CIF, SD и до HDTV, в нашем исследовании использовалось разрешение CIF. На рис. 18.5 показано, что увеличение скорости на формате SD (720 х 480) несколько меньше, чем на формате CIF (352 х 288). В то время как ускорение определяется такими факторами, как синхронизация и степень параллелизма, на рис. 18.13 видно, что количество синхронизации в секунду при кодировании SD-видео меньше, чем при кодировании CIF-видео. Более того, формат SD имеет более высокую степень параллелизма. Мы могли бы попытаться понять причины того, почему ускорение при кодировании видео более высокого разрешения меньше, чем при кодировании видео с более низким разрешением. * 0 2 4 6 8 10 12 14 16 18 20 22 Время (секунды) Рис. 18.13. Количество синхронизации в секунду при кодировании Выводы относительно поточной обработки По мере развития стандарт кодека становится все более сложным, в результате процессы кодирования и декодирования требуют значительной вычислительной мощности. Стандарт Н.264 включает в себя некоторые новые возможности и требует гораздо большего объема вычислений, чем большинство существующих стандартов, таких как MPEG-2 и MPEG-4. Даже после оптимизации при помощи мультимедийных команд кодировщик Н.264 на разрешении CIF все еще недостаточно быстр, чтобы обрабатывать видео в реальном времени. Таким образом, эксплуатация параллелизма на уровне потоков для повышения производительности кодировщиков Н.264 становится весьма привлекательной. Практический пример, представленный в данной главе, показывает, что много- поточность на основе программной модели ОрепМР предоставляет простой, но эффективным способ эксплуатации параллелизма, требуя только нескольких дополнительных прагм в последовательном коде. Разработчики могут доверить
Основные моменты 305 компилятору автоматическое конвертирование последовательного кода в многопоточный, добавив OpenMP-прагмы. Результаты замеров производительности показали, что при очень малых дополнительных затратах код, сгенерированный компилятором производства Intel, дает на архитектуре с поддержкой гиперпоточности оптимально более высокую скорость по сравнению с хорошо оптимизированным последовательным кодом (примерно до 4 раз без поддержки гиперпоточности и еще примерно 20 % за счет гиперпоточности). Основные моменты При реализации параллелизма в приложении помните следующие основные моменты: □ Разберитесь в приложении, чтобы выбрать лучшую схему декомпозиции (по задачам или по данным) с целью достижения оптимальной масштабируемости и балансировки нагрузки. □ Тщательно выбирайте детализацию параллелизма (например, на уровне кадров или на уровне слоев) с целью эксплуатации нужного объема параллелизма при минимальных издержках на синхронизацию. □ Используйте такие инструменты, как анализатор производительности VTune™ и профилировщик потоков, для измерения производительности на разных уровнях (таких как метрики микроархитектуры) и распределения времени между занятостью и ожиданием потоков, чтобы понять, имеется прирост или потеря производительности, и найти возможности для дальнейшей настройки с целью повышения производительности.
Разработка с прицелом на производи тельность Оптимизировать программное обеспечение можно на любом этапе разработки приложения, но чем раньше вы ее начнете, тем лучше. Обычно при позднем начале сужается область охвата оптимизации и появляется тенденция к ограничению в реализации алгоритмов и выборе команд, то есть вместо широкой высокоуровневой оптимизации дело заканчивается добавлением здесь и там нескольких SIMD-команд. Высокоуровневая оптимизация охватывает базовую архитектуру программного обеспечения, основные структуры данных и буферы, алгоритмы, эталоны доступа к памяти и, конечно, параллелизм. Именно в рамках подобной высокоуровневой оптимизации можно добиться серьезных подвижек в производительности при относительно небольших усилиях. Если только вы не разработчик маленьких приложений, для достижения ощутимого прироста производительности на локальном уровне вам потребуется гораздо больше усилий, чем на высоком (фундаментальном). Старое клише: «надо заложить прочный фундамент» в сфере оптимизации программного обеспечения по-прежнему живет и здравствует. Перед тем как писать код, подумайте о производительности и о том, какие фундаментальные вещи можно сделать, чтобы обеспечить высокую производительность приложения. При закладке фундамента производительности упор делается на выборе и оценке критически важных алгоритмов и того, как в соответствии с этими алгоритмами данные хранятся и перемещаются в приложении. Цель — найти алгоритм и общую архитектуру, которые могут быть легко адаптированы для поточной обработки, хорошо масштабируются на несколько процессоров, не слишком загружают пропускную способность памяти, эффективно используют кэши процессоров, хранят данные в удобном с точки зрения технологии SIMD формате, не ограничены некоторыми медленными командами (наподобие вычисления трансцендентных синуса, косинуса, логарифма и т. д.), не создают узких мест в других подсистемах компьютера (сетевом адаптере или жестком диске). Это может показаться очень трудной и может быть даже неразрешимой задачей, но помните,
Память и параллелизм 307 что любое время, затраченное на улучшение «фундамента», означает сокращение числа мучительных попыток сделать приложение чуточку быстрее тогда, когда времени уже нет ни на что, кроме оптимизации одной-двух функций на локальном уровне. Хорошие алгоритмы и варианты организации данных являются основой быстродействующего приложения, и их удачное использование может открыть множество дополнительных возможностей для повышения производительности приложения и даже избавить от необходимости задействовать что-нибудь еще, кроме компилятора. Перемещение данных Под перемещением данных понимается то, как и в каком объеме данные поступают в процессор и из него, и сколько раз выполняются эти операции. Вопросы перемещения данных могут обеспечить как рост, так и падение производительности, особенно когда дело касается нескольких процессоров и больших наборов данных. Хорошее перемещение данных означает, что данные попадают в кэши процессоров, обрабатываются там, удаляются из кэшей и никогда больше в них не загружаются (при этом, что суммарно необходимый для всех потоков объем памяти соответствует пропускной способности памяти платформы). При разработке приложения продумайте вопрос о том, как и когда будет использоваться каждый элемент данных. Ищите предсказуемые эталоны перемещения данных, обеспечивающие эффективность кэша, избегайте случайных обращений к памяти и промахов кэша (которые им сопутствуют). Стремитесь к таким решениям, которые бережно расходуют память, старайтесь избегать любых ненужных перемещений данных в памяти. Динамически выделяемые связанные структуры данных, такие как списки, очереди и деревья, часто являются источником проблем в отношении перемещения данных и эффективности кэша, поскольку память становится фрагментированной (в отличие от аккуратно кэшируемых фрагментов). При перемещении данных по таким структурам процессору приходится загружать такую фрагментирован- ную память, что в свою очередь порождает промахи кэша. Эти структуры также представляют проблему для эффективности кэша, поскольку редко обеспечивают полное заполнение строки кэша. Преобразование подобных структур данных в непрерывные массивы или, по крайней мере, поддержка индексации массива поможет ограничить требования приложения к подсистеме памяти. Память и параллелизм Введение в проект решения программных потоков означает появление целого вороха новых проблем. Если не считать относительно простой выбор между функциональной декомпозицией и декомпозицией по данным, остается самое важное — проблема памяти, а именно — сколько памяти расходуется в единицу времени.
308 Глава 19 • Разработка с прицелом на производительность Все компьютеры имеют ограниченную пропускную способность памяти. Если вы думаете, что с этим почти ничего нельзя сделать, то вы правы. Однако вы можете (и это действительно важно) управлять тем, как ваше приложение расходует эту пропускную способность. Рассмотрим программу сжатия и архивирования наподобие известного архиватора gzip. Он загружает группу файлов, сжимает каждый из них по отдельности, а затем стыкует вместе. Остановитесь на минутку и подумайте, как можно было бы ввести в gzip параллелизм. Один из самых простых способов — разделить файл и сжимать его частями с использованием нескольких потоков. Но есть и другой такой же правильный метод декомпозиции по данным — сжимать одновременно несколько целых файлов, каждый в отдельном потоке. Какой метод лучше? Рассмотрим крайний случай, когда количество процессорных ядер равняется количеству сжимаемых файлов. Итак, если вы сжимаете 200 файлов, то компьютер имеет 200 процессорных ядер. Если вы выбрали вариант со сжатием каждого файла целиком в отдельном потоке, то все 200 файлов необходимо открыть, прочитать с диска, загрузить в кэши процессоров и сохранить, причем все одновременно. Это был бы хаос. Жесткий диск работал бы бесконечно, объем необходимой оперативной памяти равнялся бы сумме размеров всех файлов, кэши процессоров были бы перегружены. Наоборот, чтение по одному файлу, разделение его на части по размеру кэша, сжатие этих частей несколькими потоками и сохранение только одного файла в каждый момент времени потребовало бы гораздо меньше памяти, не говоря уже о требованиях к дисковой подсистеме. На реализацию такого подхода может потребоваться больше усилий, особенно если версия с одним потоком на файл уже написана, но в длительной перспективе решение с несколькими потоками на один файл дает более прочный «фундамент». Еще более фундаментальным решением было бы использование неблокирующего ввода-вывода с наложением, позволяющим загружать следующий файл одновременно со сжатием несколькими потоками текущего файла и сохранением предыдущего. Эксперименты Вы можете и должны экспериментировать, проверяя производительность и прототипы приложения, чтобы определить, является ли удачным в вашем приложении выбранный вариант поточной обработки и перемещения данных. Проведя эксперимент, в котором обращения к памяти выполняются таким же образом, как в приложении или алгоритме, но не производится никаких вычислений, вы узнаете максимальную скорость выполнения данной части вашего приложения. Простых операций загрузки, копирования и сохранения памяти должно быть достаточно для доступа к памяти репрезентативным образом. Однако здесь следует помнить, что компилятор может обнаружить, что загрузки ничего не делают, и полностью удалить этот код. Не забудьте изучить ассемблерный код компилятора, чтобы не оказаться в такой ситуации, и если оптимизирующий движок компилятора
Алгоритмы 309 упорно игнорирует ничего не делающий цикл загрузки, замените этот цикл ассемблерным кодом. При помощи подобных экспериментов можно быстро и легко проанализировать и изменить алгоритмы, чтобы улучшить эталоны доступа к памяти или сократить требуемый объем памяти с целью повышения производительности. После того как требуемый эталон определен, можно добавить вычисления и проконтролировать производительность уже с ними — это позволит удостовериться, что память не является узким местом. По мере добавления вычислений проверяйте, насколько ваш код оказывается медленнее «скорости света» (от версии без вычислений). Если производительность стала падать, можете попробовать другие решения, пытаясь достичь компромисса между эффективностью вычислений и расходованием памяти. При разработке структур данных помните о следующем: □ Эффективность кэша. Весьма расточительно передавать по шине из памяти и в память никогда не используемые данные только потому, что они являются частью загружаемой строки кэша. Для повышения эффективности кэшей процессоров найдите способы организации данных, позволяющие пересылать меньше данных из памяти и в память. Совершенствуйте алгоритмы и использование ими структур данных с целью минимизации конфликтов кэша, загрузок кэша из-за его недостаточной емкости, ложного распределения и бесполезных загрузок данных памяти. Подумайте, каким мог бы быть самый лучший вариант организации данных, и затем попытайтесь приблизиться к нему (подстраивая при необходимости алгоритм). Помните, если проводить небольшие эксперименты для выяснения производительности, будет несложно опробовать несколько разных структур данных, чтобы найти ту, которая работает лучше всех. □ Параллелизм команд. Правильные организация, выравнивание и заполнение данных могут облегчить введение параллелизма на уровне команд. Обеспечьте (пока это еще не сложно) соответствие ваших данных и алгоритмов требованиям SIMD-команд и многопоточности. Даже если вы не планируете использовать SIMD-команды или многопоточность, по крайней мере продумайте, какой формат будет наиболее благоприятным для технологии SIMD, и двигайтесь в этом направлении. Из того, что вы сами непосредственно не используете SIMD- команды, вовсе не следует, что компилятор не вставит их автоматически. Кроме того, не забывайте о необходимости минимизировать зависимости по данным. Алгоритмы На стадии разработки вы, вероятно, можете очень легко угадать, какие алгоритмы будут самыми дорогостоящими в смысле времени вычислений и требований к памяти. Эти алгоритмы, в конце концов, захватят основную часть вычислительных ресурсов, и именно поэтому на их оптимизацию при разработке требуется потратить львиную долю времени.
310 Глава 19 • Разработка с прицелом на производительность Вычислительная сложность (см. главу 6) является критически важной исходной точкой. Однако параллельно с выбором эффективного алгоритма необходимо убедиться, что он хорошо подходит для остальной части приложения и для функциональных возможностей процессора, например, для кэша и SIMD-команд. Самый быстрый алгоритм не всегда имеет самую низкую вычислительную сложность и не всегда бывает самым удобным для поточной обработки. При разработке алгоритма или процесса продумайте следующие моменты: □ Оцените, какой алгоритм является самым эффективным. Вычислительная сложность алгоритма серьезно сказывается на производительности и всегда должна быть в числе высших приоритетов. Однако для алгоритмов характерны еще и требования к памяти и командам, проблемы масштабируемости, зависимости по данным. При выборе алгоритма необходимо учитывать всю палитру. Перед тем как начать пробовать другой алгоритм, поищите пути настройки текущего, стараясь добиться от него максимально возможной производительности. Подробности смотрите в главе 6. □ Не ограничивайтесь эталонами доступа к памяти и эффективности кэша. Обычно сам алгоритм диктует эталоны доступа к памяти и эффективности кэша. Изучите память, расходуемую конкретным заданием или алгоритмом, чтобы определить, как используется кэш. Ищите способы минимизировать промахи кэша. Для этого может потребоваться изменить структуры данных, сократить объем расходуемой памяти или применить любой другой вариант оптимизации памяти из описанных в главе 8. Если возможно, определите, будет ли алгоритм вызываться с теплым (данные уже в кэше) или холодным (данные необходимо загружать) кэшем, и можно ли оставить данные в кэше для последующего использования другой функцией (в случае необходимости). □ Оставляйте возможности для использования SIMD-команд. Анализируйте алгоритмы на предмет возможного использования SIMD-команд. Самым быстрым способом получить дополнительный прирост производительности является выполнение большего количества операций за прежнее время за счет параллелизма на уровне команд. Если данные хранятся в благоприятном для технологии SIMD формате, то гораздо меньше потерь будет происходить во внутренних циклах. Большинство подобных циклов выполняются в три этапа: преобразование исходных данных в формат, благоприятный для SIMD, вычисления при помощи SIMD-команд, и наконец, преобразование в целевой формат. Для повышения общей эффективности цикла (отношения полезных команд к командам преобразования) поддерживайте на низком уровне непроизводительные расходы, связанные с преобразованием данных. Благоприятная для SIMD организация данных повысит шансы на то, что SIMD-команды автоматически сгенерируют для вас компилятор C++ производства Intel. Подробности см. в главе 12. □ Поддерживайте на низком уровне зависимости по данным. Зависимости по данным ограничивают максимальное быстродействие любой последовательности команд, поскольку процессор не может начать выполнение команды раньше,
Основные моменты 311 чем будут готовы ее данные. Вы можете получить хорошую оценку количества тактов, которое займет алгоритм, если просто подсчитаете самую длинную цепочку зависимых от данных команд. Некоторые алгоритмы имеют мало зависимостей, другие полны ими. Выбор алгоритма с небольшими величинами зависимостей по данным позволяет процессору менять порядок выполнения большего количества команд, чтобы максимально полно загрузить внутренние исполнительные устройства. □ Планируйте наличие нескольких процессоров. Производство многопроцессорных персональных компьютеров уже достаточно развито. Недорогие компьютеры с несколькими ядрами и поддержкой гиперпоточности есть везде. Хороший алгоритм должен быть способен выполняться в многопоточном режиме (чтобы соответствовать прогрессу в сфере аппаратного обеспечения и требованиям клиентов). Продумайте на этапе разработки, как обеспечить декомпозицию алгоритмов для параллельного выполнения. Даже если первая версия приложения одно- поточная, при продуманном «фундаменте» позднее будет легче повысить ее производительность. Подробности см. в главе 15. □ Заранее находите узкие места и места нехватки ресурсов. Используйте прототипы и проводите эксперименты для оценки производительности с целью максимально раннего выявления узких мест, это поможет вам выбрать хороший алгоритм. Производительность большинства программ обычно ограничивают те узкие места, которые не связаны с материнской платой компьютера (такие как сетевой доступ, скорости передачи по USB-шине и обращений к дискам). Когда узкие места известны заранее, вам будет легче спланировать использование потоков и ввода-вывода без простоев, что позволит процессору заниматься полезной работой, ожидая доступа к этим медленным ресурсам. Вместо того чтобы пытаться «выжать» больше производительности из дисковой подсистемы, время разработчика можно потратить на устранение реальных узких мест и собственно разработку. □ Избегайте использования семафоров. Выберите алгоритм с минимальным объемом синхронизации (с минимумом критических секций, мьютексов и т. п.). Синхронизация обычно губит производительность. Когда объем синхронизации высок, то процессор много времени проводит в ожидании, теряя производительность. Помните, что в вызовах операционной системы (таких как выделение памяти и вызовы интерфейсов графических устройств) также используются объекты синхронизации, что означает дополнительный простой. Основные моменты Помните о следующих проблемах этапа разработки: □ Оптимизация, проводимая на этапе разработки, может серьезно сказаться на производительности. Начинайте оптимизацию заранее!
312 Глава 19 • Разработка с прицелом на производительность □ Разрабатывайте эффективные структуры данных и буферы до разработки самого приложения, чтобы получить максимальную гибкость при закладке «фундамента» производительности вашего приложения. □ Хорошо выбранный высокоэффективный алгоритм (учитывающий перемещение данных в вашем приложении и возможности процессора) нельзя заменить ничем. а Обязательно просчитайте суммарные потребности в памяти всех потоков, а не какого-то одного. □ Разрабатывайте ваше приложение так, чтобы минимизировать количество объектов синхронизации. □ Проверяйте свои проектные решения при помощи простых экспериментов, которые можно быстро подготовить и легко проанализировать.
Сводим все вместе — базовые варианты оптимизации Теперь, когда мы уже столько знаем об оптимизации, настало время оптимизировать реальное приложение от начала до конца. В данной главе описаны базовые варианты оптимизации, в то время как в следующей главе речь пойдет о не вполне тривиальных вариантах. Перед тем как вы начнете оптимизировать приложение, нужно определиться с вашей конечной целью. Чаще всего работы по оптимизации приложения прекращаются тогда, когда достигается необходимая производительность или заканчивается выделенное на оптимизацию время (в зависимости от того, что случается раньше). Если оптимизация нацелена на готовый продукт, необходимо учитывать реально существующие компромиссы между вариантами оптимизации, функциональными возможностями и множеством других вещей. Однако во многих случаях при оптимизации гораздо удобнее ограничиться тем, чего проще всего добиться, и именно об этом рассказывается в данной главе. Легкодоступные варианты оптимизации Часто программистам просто поручают сделать нечто более быстрым. Если вы получили такое задание, то только от вас зависит, насколько далеко вы зайдете в оптимизации, поскольку никакой конкретной цели по производительности не ставится. В таких ситуациях пользуйтесь правилом 90/10, которое означает, что 90 % от максимальной производительности можно достичь ценой 10 % усилий. Следующий вопрос: как выяснить максимальную производительность? Если не усложнять, можно сказать, что определение максимальной производительности зависит от вашего приложения. Если вы пишете небольшие приложения, в которых производительность играет определяющую роль (такие как драйвер видеокарты или движок для компьютерной игры), то ваша максимальная 20
314 Глава 20 • Сводим все вместе — базовые варианты оптимизации целевая производительность должна быть установлена на гораздо более высоком уровне, чем производительность большого приложения, требующего непрерывной поддержки и расширения (такого как текстовый процессор). Основной компромисс здесь нужно искать между понятностью кода, пригодностью к многократному использованию и удобством сопровождения с одной стороны и предельно достижимой производительностью с другой. Главное — определить ту максимальную производительность, к которой имеет смысл стремиться. По мере того как вы будете становиться специалистом по оптимизации, вы начнете понимать, когда дальнейшие варианты оптимизации становятся малопонятными и слишком сложными в отладке. Именно в этой точке (когда остаются последние 10 %) можно остановиться, поскольку то, чего вы еще в состоянии добиться, редко стоит затрачиваемых на это усилий. Приложение Приложение, которое мы будем оптимизировать, называется Blend (оно имеется на веб-сайте, посвященном данной книге). Это приложение выполняет плавный переход между двумя изображениями, выводя во время перехода смешанное изображение (рис. 20.1). Подобный алгоритм может быть полезным для слайдшоу или экранной заставки. Рис. 20.1. Два исходных изображения и результирующее изображение приложения Blend
Приложение 315 При запуске программа загружает с диска два исходных изображения и сохраняет их в памяти. Затем следует обычный цикл создания окна и выдачи сообщений. Во время простоя функция под названием Blend вызывается с постоянно увеличивающейся величиной смешения, что позволяет постепенно убрать первое изображение и проявить второе. После того как остается только второе изображение, величина смешения уменьшается до появления только первого изображения. Этот постепенный переход от первого изображения ко второму и обратно повторяется до тех пор, пока пользователь не выйдет из программы (что делает ее очень удобной для профилирования). Вся самая интересная работа выполняется в функции Bl end. Она использует два исходных и одно целевое изображения. Каждый пиксел в целевом изображении вычисляется по следующей формуле: цел_пиксел ас = смешение х изображение 1 с + (1 - смешение) х изображение! цел_пикселзел = смешение х изображение!зел + (1 - смешение) х изображение!зелен целпиксел т смешение х изображение! син + (1 - смешение) х изображение! шн Здесь 0 < смешение < 1. Вот код функции Bl end: void BlendCfloat amount) { for (UINT y=0; y<bitmapBlendHeight; y++) { for (UINT x=0; x<bitmapBlendWidth; x++) { DWORD pixell = bitmaplData[x+y*bitmaplStride/4]; DWORD pixel2 = bitmap2Data[x+y*bitmap2Stride/4]; DWORD clrl. clr2; clrl - pixell » 24 & Oxff; clr2 = pixel2 » 24 & Oxff; DWORD alpha = (DWORD)((float)clrl * amount + (float)clr2 * (l.Of - amount) + 0.5f); clrl = pixell » 16 & Oxff; clr2 = pixel2 » 16 & Oxff; DWORD red = (DWORD)((float)clrl * amount + (float)clr2 * (l.Of - amount) + 0.5f); clrl = pixell » 8 & Oxff; clr2 = pixe!2 » 8 & Oxff;
316 Глава 20 • Сводим все вместе — базовые варианты оптимизации DWORD green = (DWORD)((float)clrl * amount + (float)clr2 * (l.Of - amount) + 0.5f); Clrl = pixel 1 & Oxff; clr2 = pixel2 & Oxff; DWORD blue = (DWORD)((float)clrl * amount + (float)clr2 * (l.Of - amount) + 0.5f); bitmapBlendData[x+y*bitmapBlendStride/4] = (alpha«24) + (red«16) + (green«8) + blue; } } } Когда переход выполнен, приложение использует стандартные вызовы графического интерфейса устройств для копирования целевого изображения в окно приложения. Описанный алгоритм очень простой и очень медленный — чтобы сгенерировать 20 изображений уходит около 4 секунд. Нужно его оптимизировать. Ход оптимизации Все необходимые для нашего примера файлы имеются на веб-сайте, посвященном данной книге. Каждый вариант оптимизации оформлен в виде отдельного исходного файла. Чтобы воспроизводить ход оптимизации, удаляйте один файл из собранного решения и заменяйте его другим (со следующим вариантом оптимизации). Исходный файл называется blend.cpp. Файл с первым вариантом оптимизации называется blendA.cpp, следующий файл — blendB.cpp, и т. д. Тест производительности Первый шаг в оптимизации приложения — разработка теста производительности, который вы сможете использовать, чтобы отслеживать изменения в производительности. Полное и подробное описание теста производительности есть в главе 2. Для нашего приложения тест производительности измеряет только быстродействие функции смешения, поскольку только ее мы и собираемся оптимизировать. Время, требуемое на обработку одного кадра, может быть замерено при помощи одного из инструментов, описанных в главе 3. Я использовал обычный секундомер, чтобы потом заявить про «4 секунды на 20 кадров», но такой выбор инструмента
Интерпретация результатов теста производительности 317 измерения производительности вряд ли будет нам полезен. Две простые альтернативы — это мультимедийный таймер операционной системы Windows и счетчик процессора. Поскольку начальная производительность составляет примерно 5 кадров в секунду (или 0,2 секунды на изображение), то мультимедийного таймера будет вполне достаточно, поскольку повышенная точность счетчика процессора будет нам только мешать. Для использования мультимедийного таймера Windows надо просто дважды вызвать функцию timeGetTime() — один раз перед вызовом Blend, второй после вызова. Истекшее время (полученное вычитанием результатов этих двух вызовов) и является тем временем, которое нам потребуется контролировать. Вывод этого времени в заголовке окна является простейшим способом его увидеть. Соответствующие изменения внесены в файл blendA.cpp. Текст заголовка окна обновляется при каждой перерисовке, которая происходит каждые 180-203 мс на компьютере с процессором Intel Pentium 4 и тактовой частотой 3 ГГц (ваши результаты могут конечно отличаться). Интерпретация результатов теста производительности Теперь остановитесь и задумайтесь о том, что происходит примерно за 200 мс. Размеры изображения 1536 х 1024. Это означает, что 3 145 728 (2 х 1536 х 1024) пикселов загружается и 1 572 864 пикселов сохраняется. На каждый пиксел необходимы 2 загрузки, 8 сдвигов, 8 двоичных операций и 8 умножений с плавающей точкой, 4 вычитания с плавающей точкой, 4 сложения с плавающей точкой, 3 целочисленных сложения и 1 сохранение — всего 38 операций, или 179 306 496 операций на кадр. Процессор с тактовой частотой 3 ГГц выполняет за 200 мс 600 000 000 тактов, так что на каждую операцию приходится примерно по 3,3 такта, что очень медленно. Здесь определенно можно что-то оптимизировать. Давайте посчитаем еще кое-что для того, чтобы понять, насколько быстро все это может происходить. Глядя на функцию смешения, вы можете увидеть, что четыре отдельных канала цветности независимы по данным. Анализ цепочки зависимостей по данным для одного цвета может помочь определить максимальное быстродействие данной реализации. На рис. 20.2 показана простая диаграмма цепочки зависимостей по данным (аббревиатуры УМН, ВЫЧ и СЛОЖ здесь означают, соответственно, умножение, вычитание и сложение с плавающей точкой). Цепочка зависимостей по данным состоит из 9 шагов. В идеальном случае процессор мог бы выполнить все 38 операций за 9 тактов. Девять тактов на пиксел, помноженные на количество пикселов, дают минимальное количество тактов, необходимых на один кадр — 14 155 776. Процессор с тактовой частотой 3 ГГц может делать это почти 212 раз в секунду — более чем в 40 раз быстрее текущей версии. Нам предстоит большая работа!
313 Глава 20 • Сводим все вместе — базовые варианты оптимизации выч значение 1 СЛОЖ0.5 1 Сдвиг * Другие цвета 1.. Сложение * Сохранение Рис. 20.2. Цепочка зависимостей по данным для одного цвета Улучшение преобразования чисел с плавающей точкой в длинные целые Обычно на этой стадии оптимизации самое время воспользоваться профилировщиком для выявления горячих точек. Однако данное приложение настолько простое, что четыре преобразования чисел с плавающей точкой в целые превращаются для вас в проблему производительности. В главе 11 вы узнали, как можно избавить компилятор от необходимости изменять состояние числа с плавающей точкой, что является очень длительной операцией при преобразовании числа с плавающей точкой в целое.
Реализация параллелизма в алгоритме 319 Для данного примера попробуем использовать ассемблерную команду f i stp и внутреннюю команду компилятора mmcvttsssi, чтобы понять, что из них лучше по производительности. Мы задействуем следующие две функции. inline DWORD float2LongRoundx87(float fVal) { DWORD dwVal; _asm { fid fVal fistp dwVal } return dwVal; } inline DWORD float2LongRoundSSE(float fVal) { return _mm_cvtss_si32(_mmJoad_ss(&fVal)); } Производительность обоих вариантов очень близка, и теперь время составляет от 75 до 100 мс на кадр. Даже такое небольшое изменение сделало приложение более чем в два раза быстрее. Указанные функции имеются в файле blendB.cpp на случай, если вы захотите их испробовать. Хотя оба преобразования прекрасно работают для наших целей, они не слишком эффективны, поскольку зависят от округления. Поскольку по умолчанию при преобразовании происходит округление, мы получаем то, что и ожидаем, если не считать того, что дробные значения, которые равны точно половине, округляются в сторону четного целого, например 254,5 превращается в 254. Для выполнения операции округления мы могли бы проверять управляющий регистр и устанавливать его на округление или добавлять значение 0,5 и отбрасывать лишнее при помощи команды fi sttp или _mm_cvttss_si32. Для данного приложения (даже в режиме без округления) об этом вообще можно не беспокоиться, поскольку точность для пикселов некритична. Реализация параллелизма в алгоритме В данный момент вы, наверное, смотрите на эту простую функцию и думаете о реализации параллелизма как на уровне потоков, так и на уровне команд с помощью технологии SIMD. Обычно лучше начинать с высокоуровневой оптимизации, и именно так мы поступим (хотя для данного короткого примера очередность оптимизации большого значения не имеет). Самой простой моделью поточной обработки для данной функции является декомпозиция по данным — по горизонтальным блокам: верхняя половина/нижняя
320 Глава 20 • Сводим все вместе — базовые варианты оптимизации половина (или четыре полосы, если у вас четыре процессора). Другие варианты вроде левой и правой половин или четырех четвертей будут не таким удачным выбором, поскольку при этом непрерывная память делится между разными потоками. Целью декомпозиции по данным является сохранение изоляции памяти между потоками. Использование левой и правой половин рискованно, так как на стыке между половинами разные потоки могут обращаться к одной строке кэша. Какую методику использовать для поточной обработки: Win32/POSIX/OS или ОрепМР? Это будет нашим следующим решением. В данной ситуации отлично работает ОрепМР. Вам необходимо только добавить следующую прагму в строку непосредственно перед циклом for, который выполняет итерации по рядам (см. файл blendC.cpp): #pragma omp parallel for Теперь тест производительности показывает, что время снизилось примерно до 62 с. Это примерно на 25 % быстрее, чем код выполнялся бы без поточной обработки на процессоре с поддержкой гиперпоточности. Автоматическая векторизация Теперь, когда функция у нас стала многопоточная, настало время использовать SIMD-команды (для введения параллелизма на уровне команд). У нас есть четыре варианта доступа к SIMD-командам: □ позволить ввести их компилятору при автоматической векторизации; □ использовать библиотеки классов языка C++; □ задействовать внутренние команды компилятора; □ написать требуемую функцию на ассемблере. Я думаю, проще всего позволить все сделать компилятору при автоматической векторизации. Для того чтобы компилятор проявил свои способности, нужно только изменить несколько типов данных и сделать локальные копии некоторых глобальных указателей. Следующая функция (которую можно найти в файле blendD.cpp) использует автоматическую векторизацию компилятора C++ производства Intel: void BlendCfloat amount) { int uy = bitmapBlendHeight; int ux = bitmapBlendWidth; #pragma omp parallel for for (int y=0; y<uy; y++) { DWORD* pBitmapl = (DWORD*)bitmaplData +
Автоматическая векторизация 321 y*bitmaplStride/sizeof(DWORD); DWORD* pBitmap2 = (DW0RD*)bitmap2Data + y*bitmap2Stride/sizeof(DWORD); DWORD* pBitmapBlend = (DWORD*)bitmapBlendData + y*bitmapBlendStride/sizeof(DWORD); for (int x=0; x<ux; x++) { int pixell - pBitmaplCx]; int pixel2 - pBitmap2[x]; int clrl, clr2; clrl = pixell » 24 & Oxff; clr2 = pixel2 » 24 & Oxff; int alpha = ((float)clrl * amount + (float)clr2 * (l.Of - amount) + 0.5f); clrl = pixell » 16 & Oxff; clr2 w pixel2 » 16 & Oxff; int red = ((float)clrl * amount + (float)clr2 * (l.Of - amount) + 0.5f); clrl = pixell » 8 & Oxff; clr2 - pixel2 » 8 & Oxff; int green = ((float)clrl * amount + (float)clr2 * (l.Of - amount) + 0.5f); clrl - pixell & Oxff; clr2 = pixel2 & Oxff; int blue - ((float)clrl * amount + (float)clr2 * (l.Of - amount) + 0.5f); pBitmapBlend[x] = (alpha«24) + (red«16) + (green«8) + blue; Тест производительности показывает, что выполнение этой новой версии занимает 16 мс, то есть новая версия более чем в тринадцать раз быстрее, чем исходная, к тому же она очень простая, понятная и удобная в сопровождении.
322 Глава 20 • Сводим все вместе — базовые варианты оптимизации Реализация параллелизма на уровне команд при помощи внутренних команд компилятора Поскольку цикл маленький, а книга эта — учебная, имеет смысл написать нашу функцию при помощи внутренних команд компилятора и сравнить результаты с ее компиляторной версией. Вот новая функция смешения (см. файл blendE.cpp): void Blend(float amount) { _ml28 fAmount = _mm_setl_ps(amount); _ml28 fOneMinusAmount = _mm_setl_ps(1.0f - amount); #pragma omp parallel for for (int y=0; y<bitmapBlendHeight; y++) { DWORD* pBitmapl - (DWORD*)bitmaplData + y*bitmaplStride/sizeof(DWORD); DWORD* pBitmap2 = (DWORD*)bitmap2Data + y*bitmap2Stride/sizeof(DWORD); DWORD* pBitmapBlend = (DWORD*)bitmapBlendData + y*bitmapBlendStride/sizeof(DWORD); for (int x=0; x<bitmapBlendWidth; x++) { // 8-разрядные пикселы в младшей части DWORD _ml28i pixell = _mm_cvtsi32_sil28(pBitmapl[x]); _ml28i pixel2 = _mm_cvtsi32_sil28(pBitmap2[x]); // расширить каждые 8 бит до 32 бит pixell = _mm_unpacklo_epil6(_mm_unpacklo_epi8(pixell, _mm_setzero_si 1280). _mm_setzero_si 1280); pixel2 = _mm_unpacklo_epil6(_mm_unpacklo_epi8(pixel2, _mm_setzero_si 1280), _mm_setzero_si 1280); // преобразовать 32-разрядные целые пикселы // в число с плавающий точкой одинарной точности _ml28 fPixell ■? _mm_cvtepi32_ps(pixell); _ml28 fPixe!2 = _mm_cvtepi32_ps(pixel2);
Основные моменты 323 // выполнить умножения fPixell = _mm_mul_ps(fPixell, fAmount); fPixel2 = _mm_mul_ps(fPixel2, fOneMinusAmount); // добавить пикселы с плавающей точкой и преобразовать // обратно в 32-разрядные целые _ml28i destPixel - _rnm_cvtps_epi32(_mm_add_ps( fPixell, fPixel2)); // упаковать 32-разрядные целые пикселы // обратно в 8-разрядные пикселы destPixel = _mm_packus_epil6(_mm_packs_epi32( dest Pi xel, _mm_setzero_si 1280), _mm_setzero_sil280); pBitmapBlend[x] = _mm_cvtsil28_si32(destPixel); } } } Эта новая версия также выполняется за 16 мс, но она гораздо сложнее, чем компиляторная версия, больше подвержена ошибкам и не имеет преимуществ по производительности. Сводка вариантов оптимизации На данный момент большая часть вариантов оптимизации нами уже реализована, и надеюсь, вы почувствовали, что это было действительно очень просто. В нашем случае оптимизация касалась двух вещей: устранения непроизводительных издержек (преобразование числа с плавающей точкой в длинное целое и индексация массива) и введения параллелизма. Код по-прежнему вполне понятный, короткий, пригодный для многократного использования и масштабируемый, причем ОрепМР дает нам все это совершенно бесплатно. Наша цель 90/10 достигнута. Оптимизацию такого уровня вы должны выполнять чисто инстинктивно для каждого алгоритма, который пишете. Потратить час времени на то, чтобы поднять производительность более чем в десять раз — такая игра стоит свеч! Основные моменты Начиная процесс оптимизации, помните о следующем: □ Не думайте, что вам нужен какой-нибудь особенный профилировщик, все, что нужно для начала — это хороший тест производительности.
324 Глава 20 • Сводим все вместе — базовые варианты оптимизации □ На самых ранних стадиях разработки продумывайте механизмы введения параллелизма средствами программных потоков и технологии SIMD. Такой подход поможет вам адаптировать ваши структуры данных для многопоточной обработки и применения SIMD-команд. □ Никогда не отказывайтесь от автоматической векторизации, предлагаемой компилятором. Она даст вам очень приличную производительность, при этом исходный код останется понятным и удобным в сопровождении.
Сводим все вместе — последние десять процентов В данной главе рассказывается о следующем уровне оптимизации — это те самые последние десять процентов. Вы можете спросить, а стоит ли игра свеч? Иногда да. Вам может встретиться такой критически важный алгоритм, который стоит и дополнительных затрат времени, и дополнительного тестирования, и дополнительных расходов на поддержку, и малопригодное™ к многократному использованию. В предыдущей главе реализация нескольких вариантов оптимизации, нацеленных на устранение непроизводительных издержек и введение параллелизма, подняли производительность более чем в десять раз. В данной главе мы продолжим этот процесс и реализуем еще несколько вариантов оптимизации. Скорость «света» На протяжении всей книги мы говорим о том, насколько быстро может в принципе работать алгоритм. В данном примере простой эксперимент (в котором одно изображение только копируется в нужное место) станет грубым приближением потолка производительности приложения. Однако для этого необходим более точный таймер, чем функция timeGetTime(). В качестве ее замены мы используем следующую функцию: DWORD GetRDTSCO { _asm rdtsc; } Эта короткая функция вызывает функцию rdtsc, которая читает 32-разрядный счетчик времени и помещает его значение в регистр ЕАХ, где компилятор как раз и ожидает увидеть возвращаемые значения, передаваемые назад вызывавшей функции. Поскольку разнообразие компьютерных систем приводит к тому, что счетчик
326 Глава 21 • Сводим все вместе — последние десять процентов генерирует много похожих (но разных) значений, то минимальное значение будет вычисляться при помощи следующего кода: DWORD MinTime = Oxffffffff; DWORD StartTime - GetRDTSCO; Blend(blendVal); DWORD ElapsedTime - GetRDTSCO - StartTime; MinTime = min(MinTime. ElapsedTime); Теперь для вычисления скорости «света» нужна новая функция, которая просто загружает память. Эта новая функция будет использоваться для загрузки первого растрового изображения, а функция memcpy — для копирования второго растрового изображения в смешанное изображение. Приведенная далее функция загрузки памяти написана на ассемблере — это позволяет избежать ее возможного удаления компилятором (по причине того, что она ничего не делает). void LoadMemory(const void* pBitmap, long NumBytes) { asm { mov ecx, NumBytes shr ecx, 2 mov esi, pBitmap rep lodsd } } Новая функция смешения выглядит так (см. файл blendF.cpp): void BlendCfloat amount) { #pragma omp parallel for for (int y=0; y<bitmapBlendHeight; y++) { DWORD* pBitmapl = (DWORD*)bitmaplData + y*bitmaplStride/sizeof(DWORD); DWORD* pBitmap2 - (DWORD*)bitmap2Data + y*bitmap2Stride/sizeof(DWORD); DWORD* pBitmapBlend - (DWORD*)bitmapBlendData + y*bitmapBlendStride/sizeof(DWORD); LoadMemory(pBitmapl, bitmapBlendWidth * sizeof(DWORD));
Повышение эффективности SIMD-команд 327 memcpy(pBitmapBlend, pBitmap2, bitmapBlendWidth * sizeof(DWORD)); } } Скорость «света» для этого перемещения данных равна примерно 22 миллиона тактов, что дает около 7,33 мс (22 000 000/3 ГГц). Перед тем как двигаться дальше, давайте остановимся на минуту и подумаем о полученных результатах. В этом примере процессор загружает два изображения и сохраняет одно. Следовательно, за 22 миллиона тактов процессор загрузил 3 145 728 (2 х 1536 х 1024) и сохранил 1 572 864 пиксела. Это примерно 18 миллионов байтов, прошедших по шине за 7,3 мс, или 2,5 гигабайта за секунду. Это не совсем шесть с лишним ожидавшихся гигабайтов в секунду, но только потому, что наше копирование памяти далеко от эффективного. Повышение эффективности SIMD-команд Как и в большинстве других алгоритмов, реализованных с помощью SIMD-команд, некоторое количество непроизводительных потерь неизбежно. В этом примере к потерям можно отнести все упаковки и распаковки. Можно рассчитывать, что процессор замаскирует эти операции за счет задержек других команд, но надежд на это немного, поскольку алгоритм короткий, а большинство команд является внутренними командами компилятора. Следовательно, нам самим нужно получше использовать команды. Посмотрите на последнюю последовательность команд упаковки: // упаковать 32-разрядные целые пикселы обратно в 8-разрядные пикселы destPixel = _mm_packus_epil6(_mm_packs_epi32(destPixel. _mm_setzero_si 1280), _mm_setzero_si128()); Две команды упаковки расходуются понапрасну на упаковку нулей (в то время как они должны упаковывать пикселы). То же самое происходит и при распаковке, где происходят ненужные загрузки. Для исправления ситуации нам нужно обрабатывать четыре пиксела за цикл. При этом для максимальной производительности требуются данные, выровненные по границе 16 байт. Проблема легко решается заменой вызовов mal 1 ос вызовами _al igned__mal 1 ос. Нам необходимо также обеспечить, чтобы изображение имело размер, кратный четырем. К счастью, это также легко исправить, применив к ширине смешанного изображения и ~3 операцию побитового И. После введения этих двух изменений мы можем четырежды развернуть цикл и задействовать эти неиспользованные команды упаковки. Вот соответствующий код (см. файл blendG.cpp):
328 Глава 21 • Сводим все вместе — последние десять процентов void Blend (Л oat amount) { _ml28 fAmount = _mm_setl_ps(amount); _ml28 fOneMinusAmount - _mm_setl_ps(1.0f - amount); #pragma omp parallel for for (int y=0; y<bitmapBlendHeight; y++) { _ml28i* pBitmapl - (_ml28i*)bitmaplData + y*bitmaplStride/sizeof (_ml28i); _ml28i* pBitmap2 - (_ml28i*)bitmap2Data + y*bitmap2Stride/sizeof( ml28i); _ml28i* pBitmapBlend t (_ml28i*)bitmapBlendData + y*bitmapBlendStride/sizeof(_jnl28i); for (int x=0; x<bitmapBlendWidth/4; x++) { // загрузить четыре 8-разрядных пиксела __ml28i pixell_1234 - pBitmapl[x]; ml28i pixel2_1234 = pBitmap2[x]; // расширить каждые 8 бит до 32 бит // сначала обработать пиксел 1 _ml28i tempO, tempi; tempO = _mm_unpacklo_epi8(pixell_1234, _mm_setzero_si 1280); tempi ■ _mm_unpackhi_epi8(pixell_1234, _mm_setzero_sil280); _ml28i pixell_l = _mm_unpacklo_epil6(temp0, _mm_setzero_si 1280); __ml28i pixel1_2 = _mm_unpackhi_epil6(tempO. _mm_setzero_sil280); _ml28i pixel1_3 = _mm_unpacklo_epi16(tempi, _mm_setzero_si 1280); __ml28i pixel1_4 * _mm_unpackhi_epil6(templ, _mm_setzero_si 1280); // сначала обработать пиксел 2 tempO = _mm_unpacklo_epi8(pixel2_1234, mm setzero si 1280);
Повышение эффективности SIMD-команд 329 tempi = _mm_unpackhi_epi8(pixel2_1234, _mm_setzero_si128()); __ml28i pixe!2_l - _mm_unpacklo_epil6(temp0. _mm_setzero_sil280); __ml28i pixel2_2 = _mm_unpackhi_epil6(temp0, _mm_setzero_sil280); _jnl28i pixel2_3 = _mm_unpacklo_epi16(tempi. _mm_setzero_si128()); ml28i pixe!2_4 ■ _mm_unpackhi_epi16(tempi, mm setzero sil280); // преобразовать 32-разрядные целые пикселы // в числа с плавающей точкой одинарной точности _ml28 fPixellJ. - _mm_cvtepi32_ps(pixell_l); _ml28 fPixell_2 - _mm_cvtepi32j)s(pixell_2); _ml28 fPixell_3 = _mm_cvtepi32_ps(pixell_3); _ml28 fPixellJ - _mm_cvtepi32_ps(pixell_4); _ml28 fPixel2_l = _mm_cvtepi32_ps(pixel2_l); _ml28 fPixel2_2 - _mm_cvtepi32_ps(pixel2_2); _ml28 fPixel2_3 - _mm_cvtepi32_ps(pixel2_3); _ml28 fPixe!2_4 - _mm_cvtepi32_ps(pixe12_4); // выполнить умножения fPixell_l - _mm_mul_ps(fPixell_l. fAmount) fPixell_2 = jmynulj)S(fPixell_2. fAmount) fPixe!l_3 - _mm_mul_ps(fPixell_3. fAmount) fPixell_4 - _mm_mul_ps(fPixell_4, fAmount) fPixel2_l - _mm_mul_ps(fPixel2_l, fOneMinusAmount) fPixel2_2 = _mm_mul_ps(fPixel2_2. fOneMinusAmount) fPixe!2_3 = _mm_mul_ps(fPixel2_3, fOneMinusAmount) fPixe!2_4 = _mm_mul_ps(fPixel2_4, fOneMinusAmount) // добавить пикселы с плавающей точкой // и преобразовать обратно к 32-разрядным целым _ml28i destPixel__l = _mm_cvtps_epi32(_mm_add_ps( fPixellJ. fPixe!2_D); _ml28i destPixel_2 - _mm_cvtps_epi32(_mm_add_ps( fPixellJ, fPixe!2_2)); _ml28i destPixel_3 = _mm_cvtps_epi32(_mm_add_ps( fPixell 3, fPixe!2 3));
330 Глава 21 • Сводим все вместе — последние десять процентов ml28i destPixel_4 = _mm_cvtps_epi32(_mm_add_ps( fPixell_4, fPixe!2_4)); // упаковать 32-разрядные целые пикселы // обратно в 8-разрядные пикселы __ml.28i destPixel_1234 = _mm_packus_epil6( _mm_packs_epi 32(destPi xel_l.destPi xel_2), _mm_packs_epi32(destPixel_3,destPixel_4)); pBitmapBlend[x] = destPixel_1234; } } } Этот код выполняется 27 миллионов тактов. Он несколько быстрее, чем версия с автоматической векторизацией из предыдущей главы, но по-прежнему несколько медленнее, чем скорость «света». Последняя оптимизация Для того чтобы сделать этот код еще быстрее, необходимо изменить правила. Если мы можем допустить возможность потери точности, то нам могут помочь 16-разрядные умножения. В наборе SIMD-команд есть команда, позволяющая умножать два 16-разрядных значения без знака и получать старшие или младшие 16 бит результата — и так по восемь значений одновременно. Эта команда превосходно подходит для обработки значений с фиксированной точкой. Все, что вам необходимо сделать — провести масштабирование максимального 16-разрядного значения (65 535) по переменной amount (величина смешения, представляющая собой число с плавающей точкой от 0 до 1), а затем использовать его для умножения наших пиксельных данных. Например, пусть величина смешения равна 0,25, а величина наших пиксельных данных — 90. Проведем проверку: 0,25x65535=16 383 90 х 16383 - 1 474 470 1474 470/65 535 = 22 0,25 х 90 = 22 (значения совпадают) Еще одна оптимизация заключается в способе записи данных. Поскольку мы работаем с большими растровыми файлами, при сохранении кэш действительно мешает. Для записи данных лучше было бы использовать команды прямой (потоковой) записи. Таким образом, кэш потребуется исключительно для чтения двух изображений и не будет «загрязняться» смешанным изображением.
Последняя оптимизация 331 Итак, вот как выглядит функция Blend, в которой используются 16-разрядные числа с фиксированной точкой и потоковые операции сохранения (см. файл blendH.cpp): void Blend(f1oat amount) { WORD FixedPtAmount = (WORD)(65535.Of * amount); _ml28i Amount = _mm_setl_epi16(FixedPtAmount); // да, это вызывает ftol, но только один раз __ml28i OneMinusAmount = _mm_setl_epi16(65535- FixedPtAmount); #pragma omp parallel for for (int y=0; y<bitmapBlendHeight; y++) { _ml28i* pBitmapl = (_ml28i*)bitmaplData + y*bitmaplStride/sizeof (_ml28i); _ml28i* pBitmap2 = (__ml28i*)bitmap2Data + y*bitmap2Stride/sizeof (_ml28i); __ml28i* pBitmapBlend = (_ml28i*)bitmapBlendData + w y*bitmapBlendStride/sizeof(_ml28i); for (int x=0; x<bitmapBlendWidth/4; x++) { // загрузить четыре 8-разрядных пиксела ml28i pixell_12345678 = pBitmapl[x]; __ml28i pixel2_12345678 = pBitmap2[x]; // расширить каждые 8 бит до 16 бит _ml28i pixell_1234 = _mm_unpacklo_epi8( pixel 1_12345678, _mm_setzero_si 1280); __ml28i pixel1_5678 = _mm_unpackhi_epi8( pi xel 1_12345678. _mm_setzero_si 1280); __ml28i pixel2_1234 = _mm_unpacklo_epi8( pixe!2_12345678, _mm_setzero_si1280); _ml28i pixel2_5678 - _mm_unpackhi_epi8( pi xel2_12345678, _mm_setzero_si1280); // выполнить умножения pixell_1234 = _mm_mulhi_epul6(pixell_1234,
332 Глава 21 • Сводим все вместе — последние десять процентов Amount); pixel1_5678 = _mm_mulhi_epul6(pixell_5678. Amount); pixel2_1234 = _mm_mulhi_epul6(pixel2_1234, OneMinusAmount); pixe!2_5678 = _mm_mulhi_epul6(pixel2_5678, OneMinusAmount); _ml28i destPixel_1234 - _mm_adds_epul6( pixe11_1234. pixe!2_1234); _ml28i destPixel_5678 = _mm_adds_epul6( pixell_5678, pixel2_5678); _mm_stream_si128(pBitmapBlend+x, _mm_packus_epil6(destPixel_1234, destPixel 5678)); } } } Производительность составляет 22 миллиона тактов — в 27 раз быстрее, чем исходная версия! Выводы относительно вариантов оптимизации В данный момент мы уже далеко пересекли границу 90/10. Вероятно, еще некоторый прирост производительности можно получить при помощи хитрых предвы- борок памяти или кэширования предварительно смешанных изображений. Помогут также изменения в процессоре, в частности, кэш уровня 2 такого размера, чтобы в нем поместились оба исходных изображения, более быстрая память, большее количество ядер или меньшие латентности команд. Первая версия этого простого приложения тратила 200 мс, или примерно 600 миллионов тактов. Последняя и самая замечательная оптимизированная версия срезала примерно 96 % этих тактов. Основные моменты При оптимизации не забывайте о следующем: □ Несложный эксперимент со скоростью «света» позволит оценить оставшиеся возможности оптимизации.
Основные моменты 333 □ Могут помочь вычисления с целыми числами и числами с фиксированной точкой, поскольку вы получаете в два раза больше умножений на команду, чем в случае умножений с плавающей точкой в SIMD-командах, в результате вам, возможно, удастся сократить издержки применения SIMD-команд. □ Если вам не нужно читать вывод из одной функции в следующей функции, то используйте команды потоковой записи (чтобы избежать бесполезных удалений данных из кэша).
Литература Книги и статьи 1. Allen, Randy and Ken Kennedy. 2002. Optimizing Compilers for Modern Architectures. San Francisco, California: Morgan Kaufmann Publishers. 2. Banerjee, Utpal. 1993. Loop Transformations for Restructuring Compilers: The Foundations. Series on Loop Transformations for Restructuring Compilers. Boston, Massachusetts: Kluwer Academic Publishers. 3. Banerjee, Utpal. 1994. Loop Parallelization. Series on Loop Transformations for Restructuring Compilers. Boston, Massachusetts: Kluwer Academic Publishers. 4. Banerjee, Utpal. 1997. Dependence Analysis. Series on Loop Transformations for Restructuring Compilers. Boston, Massachusetts: Kluwer Academic Publishers. 5. Barbosa, Denilson, Joao Paulo Kitajima, and Wagner Meira Jr. 1999. Real-time MPEG encoding in shared-memory multiprocessors. 2nd International Conference on Parallel Computing Systems. (Ensenada) CICESE Research Center. 6. Bik, Aart J. С 2004. The Software Vectorization Handbook: Applying Multimedia Extensions for Maximum Performance. Hillsboro, OR: Intel Press. 7. Bik, Aart J. C, Milind Girkar, Paul M. Grey, and Xinmin Tian. 2002. Automatic intra-register vectorization for the Intel Architecture. International Journal of Parallel Programming 30(2): 65-98. 8. Binstock, Andrew. 2005. Programming with Intel Extended Memory 64 Technology: Migrating Software for Optimal 64-bit Performance. Hillsboro, OR: Intel Press. 9. Chen, Y. K., M. Holliman, E. Debes, S. Zheltov, A. Knyazev, S.Bratanov, R. Belenov, and I. Santos. 2002. Mediaapplications on Hyper-Threading Technology Intel Technology Journal (February): 47-57.
Книги и статьи 335 10. Chow, Fred, Sun Chan, Robert Kennedy, Shim-Ming Liu, Raymond Lo, and Peng Tu. (1997) A new algorithm for partial redundancy elimination based on SSA form. Proceedings of the ACM SIGPLAN '97 Conference on Programming Language Design and Implementation. 32(5): 273-286. 11. Chow, Jyh-Herng, Leonard E. Lyon, and Vivek Sarkar. 1996. Automatic paral- lelization for symmetric shared-memory multiprocessors. CASCON'96: Meeting of Minds (November): 76-89. 12. Cormen, Thomas H., Charles E. Leiserseon, Ronald L. Rivest. 1990. Introduction to Algorithms. New York, NY: McGraw-Hill. 13. ISO/IEC. 1998. Information Technology —Coding of Audiovisual Objects. Part 3: MPEG-4 Audio, Subpart 4: Time/Frequency Coding. 14496-3:1998 14. ISO/IEC. 2002. The JVT Advance Video Coding Standard: Complexity and Reference Analysis on a Tool-by-Tool Basis. 14496-10:1998 ОиЬ0 15. ISO/IEC. 2004. Information Technology -Coding of Audio-Visual Objects, Part 2: Visual. 14496-2: 2004. 16. Intel Corporation. 2005a. The High-Performance Intel C++ and Fortran Compilers. Santa Clara, CA: Intel Corporation. 17. Intel Corporation. 2005b. IA-32 Intel Architecture Optimization Reference Manual. Santa Clara, CA: Intel Corporation. 18. Intel Corporation. 2005c. IA-32 Intel Architecture Software Developer's Manual, Volume 1: Basic Architecture. Santa Clara, С A: Intel Corporation. Available at: http://developer.intel.com/. 19. Intel Corporation. 2005d. IA-32 Intel Architecture Software Developer's Manual, Volume 2: Instruction Set Reference. Santa Clara, CA: Intel Corporation. 20. Intel Corporation. 2005e. IA-32 Intel Architecture Software Developer's Manual, Volume 3: System Programming Guide. Santa Clara, CA: Intel Corporation. 21. Liang, L., X. Liu, M. Zhao, X. Pi, and A. V Nefian. 2002. Speaker-independent audiovisual continuous speech recognition. Proceedings of International Conference on Multimedia and Expo. 2 (August): 25-28. 22. Levy, Henry M., Dean M. Tullsen, and Susan J. Eggers. 1995. Simultaneous multithreading: maximizing on-chip parallelism. Paper at 22nd International Symposium on Computer Architecture (ISCA 95) 392-403. 23. Malvar, H., A. Hallapuro, M. Karczewicz, and L. Kerofsky. 2002. Low-complexity transform and quantization with 16-bit arithmetic for H.26L. International Conference on Image Processing. 2 (October): 489-492. 24. Marr, D., F. Binns, D. L. Hill, G. Hinton, D. A. Koufaty, J. A. Miller, and M. Upton. 2002. Hyper-Threading Technology microarchitecture and architecture. Intel Technology Journal Ql 3(1): 4-15. 25. OpenMP Architecture Review Board. 2005. OpenMP Application Program Interface. (Version 2.5, May).
336 Книги и статьи 26. Paver, Nigel, Bradley Aldrich, and Moinul Khan. 2004. Programming with Intel Wireless MMX Technology: A Developer's Guide to Mobile Multimedia Applications. Hillsboro, OR: Intel Press. 27. Reinders, James. 2005. VTune Performance Analyzer Essentials: Measurement and Tuning Techniques for Software Developers. Hillsboro, OR: Intel Press. 28. Sedgewick, Robert. 1998. Algorithms. Reading, MA: Addison-Wesley. 29. Shah, Sanjiv, Grant Haab, Paul Petersen, and Joe Throop. 1999. Flexible control structures for parallelism in OpenMP Paper at First European Workshop on OpenMP (EWOMP September). 30. Shen, Ke, Laurence A. Rowe, and Edward L. Delp. 1995. A parallel implementation of an MPEG-1 encoder: Faster than real-time. Paper at Conference on Digital Video Compression: Algorithms and Techniques. Proceedings of SPIE 2419 (April): 407-418. 31. Stroustrup, Bjarne. 1991. The C++Programming Language. 2 ed. Reading, MA: Addison-Wesley. 32. Su, Ernesto, Xinmin Tian, Milind Girkar, Grant Haab, Sanjiv Shah, and Paul Petersen. 2002. Compiler support for work queuing execution model for Intel SMP architectures. Paper at Fourth European Workshop on OpenMP (EWOMP September). 33. Taylor, H. H., D.Chin, and A. Jessup. 1993. An MPEG encoder implementation on the Princeton engine video supercomputer. Paper at Data Compression Conference (DCC 93). (April): 420 -429. IEEE Explorer Digital Object Identifier 10.1109/ DEC. 1993. 253107. 34. Tian, Xinmin, Aart J. С Bik, Milind Girkar, P. Grey, H. Saito, E. Su. 2002. Intel OpenMP C++/Fortran Compiler for Hyper-Threading Technology: implementation and performance. Intel Technology Journal Ql 3(1): 36-46. 35. Tian, Xinmin, Milind Girkar, Aart J. С Bik, and Hideki Saito. 2005. Practical compiler techniques on efficient multithreaded code generation for OpenMP Programs. The Computer Journal 48(1): 588-601. 36. Tian, Xinmin, Yen-Kuang Chen, Milind Girkar, Steven Ge, Rainer Lienhart, Sanjiv Shah. 2003. Exploring the use of Hyper-Threading Technology for multimedia applications with Intel OpenMP compiler. Paper at International Parallel and Distributed Processing Symposium (IPDPS' 03) (April): 36a. 37. Triebel, Walter. 2000. Itanium Architecture for Software Developers. Hillsboro, OR: Intel Press. 38. Van der Tol, E. В., E. G. T Jaspers, and R.H. Gelderblom. 2003. Mapping of H.264 decoding on a multiprocessor architecture. Paper at Image and Video Communications and Processing Conference. Proceedings of SPIE 5022 (June): 707-718. 39. Wolfe, Michael J. 1996. High Performance Compilers for Parallel Computing. Redwood City, CA: Addison-Wesley.
Интернет-ресурсы 337 40. Zhou, Xiaosong, Eric. Q. Li, and Yen-Kuang Chen. 2003. Implementation of H.264 decoder on general-purpose processors with media instructions. Paper at Image and Video Communications and Processing Conference. Proceedings of SPIE 5308 (January): 224-235. 41. Zima, Hans, and Barbara Chapman. 1990. Supercompilers for Parallel and Vector Computers. New York, NY: ACM Press. Интернет-ресурсы Информация по компиляторам Intel: http://developer.intel.com/software/products/compilers Информация по анализатору производительности VTune производства Intel: http://developer.intel.com/software/products/vtune Последняя техническая информация по продуктам Intel: http://www.intel.com/products Спецификации и документы по EWOMP: http://www.openmp.org
Алфавитный указатель автоматическая векторизация 172,186, 320 автоматическая предвыборка 100 автоматический параллелизм 250 алгоритм выбор 68 вычислительная сложность 68 параллельный 77 последовательный 77 производительность 69 сортировки быстрой 68 пузырьковой 68 универсальность 78 Эвклида 70 алгоритмическая проблема 79 АЛУ 144 анализ выборочный 79 графа вызовов 53 кодового охвата 79 производительности 22, 24,25 анализатор производительности 41 антизависимость 131, 251 аппаратная предвыборка 104, 220 арифметика насыщения 171 округления 171 арифметико-логическое устройство 144 архитектура процессора 55 ассемблер 176 аудиовизуальное распознавание речи 272 базовая оптимизация 313 балансировка нагрузки 231, 289 безусловный переход 84, 92 библиотека классов 173 битовая скорость 291 блок операций с плавающей точкой 156 предсказания переходов 56 функциональный 55, 56 блокировка 240 большая латентность 143 буфер записи 100 изображений 291 меток перехода 85 пиков 148 прямого доступа 115 слоев 291 стека возвратов 91 быстрая сортировка 68 В векторизация автоматическая 172,186, 320 диагностика 199, 200 векторизованный цикл 133 верхушка стека 171 взаимное исключение 256 видеокодек 286 виртуальная память 101 вложенный параллелизм 272
Алфавитный указатель 339 внутренняя команда компилятора 174, 32 волокно 247 воспроизводимость теста производительности 26 временная локальность 102 вскрытие недр 109,140 выборка 40,41 команд 59 событий 42 выборочный анализ 79 выборочный профилировщик 40 вызов системный 148 выполнение без горячих точек 52 медленное 51 нечастое 49 равномерное 52 частое 51 выравнивание данных 112 памяти 178 выходная зависимость 131, 251 вычисление инвариантов цикла 140 вычислительная сложность алгоритма 68 Г Ганта, диаграмма 74 гиперпоточность 224 главный поток 242 гонка 240 горячая группа 243 горячая точка 231 поиск 23 понятие 23, 49 граф вызовов 43,154 горячих точек 87 группа горячая 243 изображений 288 д данные выровненные 112 невыровненные 112 двойная точность 161 декодирование команд 59, 216 декомпозиция по данным 226, 288 по заданиям 226, 288 функциональная 240,288 денормализованное число 160 диагностика векторизации 200 диагностическое сообщение 199 диаграмма Ганта 74 динамическая чистка цикла 134 диспетчеризация процессоров 38 дополнительная оптимизация 325 доступ общий 243 приватный 243 шаговый 105 эксклюзивный 193 3 зависимость антизависимость 131, 251 выходная 131,251 по данным 63, 74,130 потоковая 251 по ходу выполнения 131, 200 циклическая 131,247 загрузка из-за конфликтов кэша 109 из-за недостаточной емкости кэша 109 невыровненная 194 после записи 251 принудительная кэша 108 разделенная 113 задача п ферзей 263 запись невыровненная 194 объединенная 106 опережающая 111,118 после загрузки 251 после записи 131,251 после чтения 131 прямая 106 разделенная 113 И идентификатор потока 279 извлечение квадратного корня 167 издержки многопоточности 302 инвариант 140
340 Алфавитный указатель инструментальный профилировщик 40 инструментовка 45 исключение взаимное 256 с плавающей точкой 157 числовое 156 исполнительное ядро 225 история переходов 85 исходный поток 242 К канал кэша 103 квадратный корень 167 кластерная система 243 код инициализации 49 обработки ошибок 49 кодирование Хаффмана 29,44 кодовый охват 43, 79 команда внутренняя компилятора 174,322 выборка 59 выполнение 61 декодирование 59,216 латентность 69, 99, 217 медленная 143 пропускная способность 69,217 удаление 64 компилятор оптимизирующий 35 параметры векторизации 186 подсказки 190 поточный 235 компиляция обратной связи 39 конвейерный параллелизм 268 концентрация 78 косвенный вызов 91 косвенный переход 84, 91 критическая секция 241 критичный переход 86 крупномодульный параллелизм 77, 226 крупномодульный поток 231 кэш данных 64,65,101,219 емкость 109 канал 103 команд 216 памяти 56 пинг-понг 234 кэш (продолжение) подсказка 100 понятие 101 попадание 102 принудительная загрузка 108 прогрев 27 промах 47, 92,102 сегмент 103 строка 103 теплый 310 трасс 56,60,65,101,216 уровня 1 65 2 65 3 65 холодный 310 эффективность 110,309 кэширование 103 Л латентность большая 143 команд 69,99,217 памяти 77, 99 понятие 69 линейная алгебра 149 логический процессор 279 логическое процессорное устройство 279 ложное распределение 233, 283 локальность временная 102 пространственная 102 ЛПУ 279 М макроблок 289 маска 94 масштабируемость 289 медленная команда 143 медленное выполнение 51 мелкомодульный параллелизм 77, 226 мелкомодульный поток 231 мертвая блокировка 240 механизм диспетчеризации процессоров 38 кэширования 103 механический секундомер 33
микрооперация 59 многопоточная модель выполнения 242 многопоточное задание 231 многопоточность 225 издержки 302 реализация средствами ОрепМР 237 многопроцессорная обработка 224 многоуровневый параллелизм 276 многоядерная система 243 модель ветвления-объединения 227 выполнения многопоточная 242 очереди заданий 257,293, 295 слоев 291 согласованности памяти 245 монитор производительности 40 мультиалгоритмический параллелизм 260, 264 мультимедийные расширения 170 мультимедийный таймер 317 Н набор сброса 245 направляемое планирование 240 насыщение 171 невыровненная загрузка 194 невыровненная запись 194 некэшируемая память 106 неограниченный указатель 203 неслучайный переход 85 нестрогая модель согласованности памяти 245 нечастое выполнение 49 нечисло 157 низкоуровневая поточная обработка 230 О обработка многопроцессорная 224 низкоуровневая 230 поточная 230 распределенная 224 общий доступ 243 объединенная запись 106 одинарная точность 161 округление 165,171 Алфавитный указатель 341 операция сброса 245,246 с плавающей точкой 156 опережающая запись 111,118 оптимизация 32-разрядных архитектур Intel 212 базовая 313 выводы 332 для конкретного процессора 36 дополнительная 325 заблуждения 21 за счет использования справочных таблиц 145 легкодоступная 313 понятие 21 приложения 314 процесс 23 структуры данных 126 функции 125 цикла 75 оптимизирующий компилятор 35 организация данных 180 основная память 101 отсутствие страницы 116 очередь заданий 257, 293,295 работ 293 рабочая 257 слоев 291 ошибка отсутствия страницы 101 П память быстродействие 99 виртуальная 101 выравнивание 178 латентность 77 некэшируемая 106 основная 101 отсутствие страницы 116 производительность 100 пропускная способность 233 с объединением записи 106 требования 76 физическая 101 параллелизм автоматический 250 вложенный 272 данных 226
342 Алфавитный указатель параллелизм {продолжение) заданий 226, 263 кадров 290 команд 74, 77, 224, 309 конвейерный 268 крупномодульный 77, 226 мелкомодульный 77, 226 многоуровневый 276 мультиалгоритмический 260, 264 потоков 225 слоев 289 среднемодульный 269 параллельное программирование 225 параллельный алгоритм 77 концентрация 78 планирование 78 понятие 77 разбиение 78 синхронизация 78 перебор с возвратами 263 перемещение данных 307 перестановка циклов 138 переход безусловный 84,92 ветвления-объединения 253 косвенный 84, 91 критичный 86 неверно предсказанный 86 неслучайный 85 предсказание 61 предсказуемость 92 прямой 84,92 случайный 85 удаление 93,94,96 условный 84,90 пинг-понг кэша 234 планирование направляемое 240 параллельного алгоритма 78 статическое 240 цикла 280 побочный эффект векторизации 203 повтор 61 подкачка 101 подсказка компилятору 190 кэша 100 подставляемый ассемблер 176 поиск горячих точек потери времени 86 поиск (продолжение) неверно предсказываемых переходов 86 проблем опережающей записи 118 промахов кэша 119 случаев отсутствия страниц 116 поле верхушки стека 171 полнота охвата теста производительности 28 понятие горячей точки 23, 49 кэша 101 латентности 69 оптимизации 21 параллельного алгоритма 77 планирования циклов 280 попадания кэша 102 последовательного алгоритма 77 привязки программных потоков 278 промаха кэша 102 пропускной способности 69 теста производительности 25 попадание кэша 102 последовательный алгоритм 77 поток главный 242 горячая группа 243 данных 105,133 исходный 242 крупномодульный 231 программный 225, 227 потоковая зависимость 251 потоковые SIMD-расширения 171 поточный компилятор 235 правило соответствия 212 предвыборка 100 аппаратная 104, 220 программная 105,114 предсказание перехода 61 предсказуемость переходов 92 преобразование Фурье 274 приватный доступ 243 привязка потока к процессору 234, 278 прикладной программный интерфейс 227 принудительная загрузка кэша 108 проверяемость теста производительности 27 программная предвыборка 105,114 программный поток 225, 227 программный профилировщик 39 программный секундомер 33
Алфавитный указатель 343 прогрев кэша 27 производительность анализатор 41 многопоточного кодировщика 295 монитор 40 памяти 100 сжатия 28 сортировки 29 промах кэша 47,92,102 пропускная способность 144 команды 69, 217 памяти 233 понятие 69 простой системы 151 простота применения теста производительности 27 пространственная локальность 102 профилировщик выборочный 40 из состава компилятора Intel 43 среды разработки Microsoft 45 инструментальный 40 программный 39 процессор архитектура 56 диспетчеризация 38 логический 279 события 220 физический 279 функциональные блоки 56 процессорное устройство логическое 279 центральное 279 процесс простоя системы 151 прямая запись 106 прямой переход 84,92 пузырьковая сортировка 68 пул команд 99, 215 Р рабочая очередь 257 равномерное выполнение 52 разбиение 78 развертывание цикла 135 разделение на блоки 109,140 разделенная загрузка 113 разделенная запись 113 распознавание речи 272 расположение мозаикой 140 распределение ложное 233, 283 работ 227,239 цикла 132 распределенная обработка 224 расщепление цикла 132, 193 регистр сквозное суммирование 180 состояния 171,219 тегов 171 управляющий 219 флагов 222 частичный останов 220 режим диагностики векторизации 200 доставки 299 округления 166 сборки 299 скалярный 165 упакованный 165 усечения 166 рекурсия 257 репрезентативность теста производительности 27 С сброс 245 свертывание цикла 136 сегмент кэша 103 секундомер механический 33 программный 33 электронный 33 секция критическая 241 семафор 247,311 сериализация 144 сжатие 28 синхронизация 78, 231 система аудиовизуального распознавания речи 272 кластерная 243 многоядерная 243 простой 151 с неоднородным доступом к памяти 243 системный вызов 148 скалярный режим 165 сквозное суммирование регистра 180
344 Алфавитный указатель скорость света 325 с лайд шоу 314 слияние циклов 134 слой 289 случайный переход 85 событие процессора 220 совпадение имен 170 сортировка быстрая 68 вставкой 122 выбором 77 производительность 29 пузырьковая 68 слиянием 77,122 справочная таблица 145 среднемодульный параллелизм 269 станция резервирования 60 статическое планирование 240 стек возвратов 91 строка кэша 103 Т таблица переходов 91 справочная 145 теплый кэш 310 тест производительности атрибуты 26 воспроизводимость 26 полнота охвата 28 понятие 25 приложения 316 примеры 28 проверяемость 27 простота применения 27 репрезентативность 27 стандартный 25 точность 28 технология ММХ 170 SIMD 169, 172 гиперпоточности 224 точка горячая 23,49,231 перехода 91 холодная 51, 231 точность вычислений с плавающей точкой 161 теста производительности 28 тракт данных узкий 170 широкий 170 трасса 60 У удаление перехода за счет дополнительной работы 96 командой CMOV 93 min/max 96 с помощью маски 94 узкий тракт данных 170 указатель неограниченный 203 функции 91 упакованный режим 165 упакованный элемент данных 170 управляющий регистр 219 условие гонок 240 условный переход 84, 90 Ф физическая память 101 физический процессор 279 функциональная декомпозиция 240, 288 функциональный блок 55, 56 функция округления 166 Фурье, преобразование 274 X Хаффмана, кодирование 29, 44 холодная точка 51, 231 холодный кэш 310 ц центральное процессорное устройство 279 цикл векторизованный 133 достоинства и недостатки 130 зависимость по данным 130 инварианты 140 ожидания 234 оптимизация 75 перестановка 138 простоя системы 151
Алфавитный указатель 345 цикл (продолжение) развертывание 135 распределение 132 расщепление 132, 193 свертывание 136 слияние 134 чистка 134 циклическая зависимость 131, 247 циклическая независимость 131 ЦПУ 279 Ч частичный останов регистра 220 частое выполнение 51 число денормализованное 160 с плавающей точкой 160 извлечение квадратного корня 167 преобразование в целое 167 числовое исключение 156 чистка цикла 134 чтение после записи 131 Ш шаговый доступ 105 широкий тракт данных 170 э Эвклида, алгоритм 70 экранная заставка 314 эксклюзивный доступ 193 электронный секундомер 33 эталон доступа 173 переходов 85 этап выдачи 58 приготовления 57 приемки 57 эффект пинг-понга кэша 234 побочный векторизации 203 совпадения имен 203 эффективность кэша НО, 123, 233, 309
Р. Гербер, А. Бик, К. Смит, К. Тиан Оптимизация ПО. Сборник рецептов Перевел с английского А. Смирнов Руководитель проекта П. Маннинен Ведущий редактор О. Некруткина Литературный редактор А. Жданов Художественный редактор Л. Аду веская Корректор Е. Каюрова Верстка Е. Егорова Подписано в печать 29.12.09. Формат 70x100/16. Усл. п. л. 28,38. Тираж 1000. Заказ 20689. ООО «Лидер», 194044, Санкт-Петербург, Б. Сампсониевский пр., д. 29а. Налоговая льгота — общероссийский классификатор продукции ОК 005-93, том 2; 95 3005 — литература учебная. Отпечатано по технологии CtP в ОАО «Печатный двор» им. А. М. Горького. 197110, Санкт-Петербург, Чкаловский пр., д. 15.
ИЗДАТЕЛЬСКИЙ ДОМ V^^ WWW.PITER.COM ПРЕДСТАВИТЕЛЬСТВА ИЗДАТЕЛЬСКОГО ДОМА «ПИТЕР» предлагают эксклюзивный ассортимент компьютерной, медицинской, психологической, экономической и популярной литературы РОССИЯ Санкт-Петербург м. «Выборгская», Б. Сампсониевский пр., д. 29а тел./факс: (812) 703-73-73,703-73-72; e-mail: sales@piter.com Москва м. «Электрозаводская», Семеновская наб., д. 2/1, корп. 1,6-й этаж тел./факс: (495) 234-38-15,974-34-50; e-mail: sales@msk.piter.com Воронеж Ленинский пр., д. 169; тел./факс: (4732) 39-61 -70 e-mail: piterctr@comch.ru Екатеринбург ул. Бебеля, д. 11 а; тел./факс: (343) 378-98-41,378-98-42 e-mail: office@ekat.piter.com Нижний Новгород ул. Совхозная, д. 13; тел.: (8312) 41 -27-31 e-mail: office@nnov.piter.com Новосибирск ул. Станционная, д. 36; тел.: (383) 363-01-14 факс: (383) 350-19-79; e-mail: sib@nsk.piter.com Ростов-на-Дону ул. Ульяновская, д. 26; тел.: (863) 269-91 -22, 269-91 -30 e-mail: piter-ug@rostov.piter.com Самара ул. Молодогвардейская, д. 33а; офис 223; тел.: (846) 277-89-79 e-mail: pitvolga@samtel.ru УКРАИНА Харьков ул. Суздальские ряды, д. 12, офис 10; тел.: (1038057) 751-10-02 758-41-45; факс: (1038057) 712-27-05; e-mail: piter@kharkov.piter.com Киев Московский пр., д. 6, корп. 1, офис 33; тел.: (1038044) 490-35-69 факс: (1038044) 490-35-68; e-mail: office@kiev.piter.com БЕЛАРУСЬ Минск ул. Притыцкого, д. 34, офис 2; тел./факс: (1037517) 201 -48-77 e-mail: gv@minsk.piter.com Cjg Ищем зарубежных партнеров или посредников, имеющих выход на зарубежный рынок. Телефон для связи: (812) 703-73-73. E-mail: fuganov@piter.com С# Издательский дом «Питер» приглашает к сотрудничеству авторов. Обращайтесь по телефонам: Санкт-Петербург - (812) 703-73-72, Москва - (495) 974-34-50 t& Заказ книг для вузов и библиотек по тел.: (812) 703-73-73. Специальное предложение - e-mail: kozin@piter.com Eg Заказ книг по почте: на сайте www.piter.com; по тел.: (812) 703-73-74 по ICQ 413763617
В этой нниге предлагаются практические рецепты создания высокопроизводительных приложений на платформах Intel. Используя простые объяснения и понятные примеры, ведущие эксперты компании Intel показывают, как решать проблемы производительности, связанные с обращениями к памяти, предсказаниями переходов, автоматической векторизацией, использованием SIMO-команд многопоточностью и вычислениями с плавающей точкой. Разработчики программного обеспечения узнают, как использовать преимущества технологий Intel EM64T, многоядерной обработки, гиперпоточности, ОрепМР и мультимедийных расширений системы команд. Эта книга познакомит вас с растущей коллекцией программных инструментов, параметров компилятора и вариантов оптимизации кода, показывая эффективные пути повышения производительности программ для платформ Intel.