Руководство по анализу и настройке производительности для современных процессоров. Анонс книги

от автора

Привет, Хабр.

Хотим поделиться с вами новостью о том, что завершаем более чем годичный фундаментальный проект — готовимся к выпуску русского издания знаменитой книги Дениса Бахвалова «Performance Analysis and Tuning on Modern CPUs: Learn to write fast software like a pro«. Денис теснейшим образом взаимодействовал с нашими редакторами, мы составили глоссарий к русскому изданию и уверены, что книга на долгие годы станет де-факто главным пособием по оптимизации производительности CPU. Книга выросла из многочисленных практических исследований, которыми Денис занимается в компании «Intel», и в качестве анонса мы хотим предложить вам перевод статьи автора, которая вышла ещё в 2019 году и может считаться рассказом о том, как зародилась идея будущей книги. В тексте под катом содержатся многочисленные ссылки на статьи Дениса из блога https://easyperf.net/notes/, который также рекомендуем пристально изучить. Русскую книгу ждите в мае.

Говорят, что «производительность всё решает». Это было актуально десять лет назад и определённо актуально сейчас. Притом, что каждый день в мире генерируются всё новые и новые данные, и на их обработку требуются постоянно растущие вычислительные мощности.

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

Что такое анализ производительности

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

Часто бывает так, что достаточно изменить в программе всего одну строку кода — и производительность программы возрастает вдвое! Суть анализа производительности именно в том, чтобы найти и исправить эту строку! Упускать такие возможности слишком расточительно.

Зачем нужно анализировать производительность?

Количество ядер в современных ЦП растёт из года в год. На момент подготовки этой статьи в продаже были серверные процессоры высшей категории, каждый из которых оснащён более чем 100 логическими ядрами. Это очень впечатляет, но не означает, что больше нет необходимости заботиться о производительности. Очень часто бывает так, что производительность приложения не улучшится, если выделить на его работу больше ядер. Если вы в будущем собираетесь масштабировать ваш продукт, то критически важно понимать, почему это происходит, и как с этим справиться. Если вы не умеете правильно анализировать производительность и настраивать её, то будете напрасно терять значительную вычислительную мощность.

Хочется спросить: так почему же аппаратное обеспечение не решает всех наших проблем? Почему их не решают компиляторы? Если коротко — они, в самом деле, помогают, но всех проблем решить не могут. Современные ЦП невероятно быстро выполняют инструкции, но ничего не могут поделать, если применяемые для решения задачи инструкции неоптимальны или даже избыточны. Обычно компиляторы применяют массу эвристик, которые, как правило, работают, но всех пограничных случаев не покрывают — покрыть их попросту невозможно.

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

Кому нужно анализировать производительность?

Современные процессоры очень сложно устроены. Однако расслабьтесь — нет в мире никого, кто во всех деталях понимал бы, как именно работает современный многоядерный процессор. К сожалению, это же означает, что и сама тема анализа производительности весьма сложна, полна всевозможных незнакомых метрик и терминов. Вот почему я всегда стремлюсь обо всём рассказывать просто. Думаю, можно доступным языком рассказать и об искусстве анализа производительности.

Ниже в этой статье я рассмотрю 4 столпа, на которых базируется эта тема:

  1. Как сконфигурировать машину и правильно измерять производительность?

  2. Какие возможности для анализа производительности предоставляются на уровне аппаратного обеспечения, и как с ним взаимодействуют софтверные инструменты?

  3. Важнейшие методологии анализа производительности.

  4. Как подходить к решению типичных проблем с производительностью.

Если хотите — считайте это нашим путевым маршрутом.

Как честно измерять производительность

На аппаратном и программном уровне реализовано множество различных возможностей, призванных как по волшебству повышать производительность. Но поведение некоторых из этих фич является недетерминированным. Возьмём, к примеру, возможность турбоускорения (turbo boost): если на «неразогретом» процессоре запустить один за другим два прогона, то первый прогон какое-то время, вероятно, будет работать в режиме повышенной тактовой частоты (то есть, быстрее), а второй — на базовой частоте, то есть, турбоускорение его не затронет. Вот одна из причин, по которым результаты могут варьироваться.

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

Профилирование приложений

Пожалуй, древнейший известный метод анализа производительности — это инструментирование кода. Мы этим постоянно занимаемся. Помните, как вам доводилось добавлять printf в начале функции, просто, чтобы подсчитать, сколько раз вызывается функция? О, я тоже так делал. Это простейший и при этом самый точный и развёрнутый способ проанализировать производительность приложения. Но у инструментирования кода есть и серьёзные недостатки. В частности, большие накладные расходы и необходимость перекомпилировать приложение всякий раз, когда мы захотим проделать с ним что-то новое. В наши дни немногие люди занимаются инструментированием кода вручную.

Шли годы, разрабатывались новые методы анализа производительности. Один из них основан на использовании прерываний мониторинга производительности (PMI) и называется «профилированием». Проще всего трактовать его так. Если вы, работая с отладчиком, будете каждую секунду приостанавливать программу и записывать, где именно вы остановились, то получите выборку (совокупность образцов). Если затем агрегировать все образцы и построить гистограмму, то вы увидите, на что именно программа тратит больше всего времени. Это предельно упрощённое описание, позволяющее составить впечатление, что именно делают инструменты профилирования, но идея похожа. Есть автоматизированные инструменты, например,  “perf” в Linux и “Intel Vtune”, записывающие тысячи прерываний (образцов) в секунду, пока работает ваша программа, а затем обобщающие информацию о них.

Базовый компонент, благодаря которому это становится возможным — это счётчик мониторинга производительности (PMC). С его помощью можно подсчитывать разные события. Например, при помощи PMC можно сосчитать, сколько ассемблерных инструкций было выполнено с момента запуска приложения. То есть, этот инструмент можно сконфигурировать так, чтобы каждая выполняемая на аппаратной платформе ассемблерная инструкция увеличивала значение этого счётчика на единицу. Базовое введение в работу блоков измерения производительности (PMU) сделано в этой статье.

Для целей профилирования PMC можно использовать несколько более хитроумным образом. Предположим, что ЦП работает на частоте 1 Ггц, то есть, выполняет 10⁹ тактов в секунду. Чтобы приостанавливать программу всякий раз после каждого из миллиона (10⁶) тактов (то есть, получает 1000 образцов в секунду), проделаем следующие шаги:

1. устанавливаем счётчик в значение -1'000'0002. Включаем подсчёт3. Дожидаемся переполнения, о котором нам сообщит процессор 3.1. Когда переполнение произойдёт — отключаем подсчёт 3.2. Отловим PMI3.3. Внутри обработчика прерываний отловим указатель на инструкцию (IP).4. Возвращаемся к шагу 1

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

О том, как в основе своей построена работа инструмента “perf” в Linux рассказано в этой статье.

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

Наконец, при анализе производительности вам может очень пригодиться такая вещь как трассировка. Если вам знакомы инструменты Linux strace/ftrace, то вас это не должно удивлять. Конечно, при мониторинге, основанном на прерываниях, мы по определению будем пропускать значительное количество интересующих нас событий — а при трассировке мы отслеживаем их все. Можно трактовать это как гибридное решение по инструментированию кода с применением мониторинга на основе прерываний. Применяя трассировку, мы получаем достоинства обоих этих методов. Трассировка обходится не так дорого как инструментирование, но позволяет собирать массу информации о выполнении программы. Возможности отслеживания работы ядер в современных ЦП позволяют отследить практически каждую отдельную ассемблерную инструкцию ценой относительно небольших издержек. Подробнее о трассировке процессора (PT) рассказано здесь.

Методологии анализа производительности

В самом незатейливом случае всё, что вам потребуется — выявить в приложении «горячие точки». Возможно, вы заметите в коде участок, который не должен выполняться настолько долго, как он фактически выполняется. В таком случае можно реализовать высокоуровневое преобразование, которое позволит оптимизировать среду выполнения. Например, дело может быть в том, что в программе делается какая-то избыточная работа, без которой в определённых ситуациях можно обойтись.

Правда, когда все легкодоступные (высокоуровневые) оптимизации уже реализованы, а вам всё ещё нужны дополнительные улучшения, чтобы соответствовать предъявленным требованиям, то нужно знать не только о таких «горячих точках», но и другую информацию. Именно эту часть работы можно охарактеризовать как «настройку» (низкоуровневые оптимизации). В современных ЦП поддерживается и такая тонкая настройка.

Важно понимать, что, даже если бы ЦП мог предоставлять всемерную поддержку — он не совершит чудес, если в приложении есть серьёзные проблемы с производительностью. Например, если в программе применяется сортировка пузырьком, то нет никакого смысла даже заглядывать в продвинутые метрики производительности ЦП — сначала нужно исправить ключевую проблему.

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

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

Одна из таких методологий называется Метод нисходящего анализа микроархитектуры (TMAM). Это процесс итерационного выявления источника проблемы — именно той точки в коде, где она возникает, с последующим исправлением. Процесс построен таким образом, чтобы можно было охарактеризовать узкое место, найденное в приложении, и отнести это бутылочное горлышко к одной из четырёх категорий: «Выключение», «Неверное предположение», «Связано с фронтендом» и «Связано с бэкендом». После этой классификации начинаем углубляться в конкретную категорию, чтобы найти именно то событие, которое ограничивает производительность приложения. Когда, наконец, однозначно определено, с каким именно узким местом мы имеем дело, нужно перезапустить приложение и найти места, в которых инициируются события именно такого типа. Устранив проблему, вы вновь возвращаетесь к процессу  TMAM и повторяете его, пока не добьётесь такой производительности, к которой стремитесь.

Анализ многопоточных приложений

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

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

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

Если вы хотите подробнее разобраться с различными аспектами анализа многопоточных приложений — почитайте серию статей Дениса Бахвалова, в которой разобрана эта тема.

Примеры настройки

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

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

Надеюсь, эта статья вам пригодится и буду очень рад, если она поможет разработчикам оптимизировать код.

ссылка на оригинал статьи https://habr.com/ru/articles/1024462/