Привет, сегодня будет объёмная и обстоятельная статья о низкоуровневом профилировании и диагностике производительности. Многократно в комментариях к докладам и статьям пользователи высказывали пожелание увидеть не просто перечисление шаблонов "как нужно" и "как не нужно", а целостную историю. Поэтому вот вам статья про конкретный кейс и его историю :). Не удивляйтесь, что начнём мы с JPA, хотя казалось бы где оно и где ассемблер. По ходу дела вы всё поймёте. Часть материала, лёгшего в основу этого доклада ранее размещалась мной здесь, поэтому если вдруг вам покажется, что где-то вы уже читали это, то чутьё вас не подвело :). И всё же эта статья не является плагиатом, а скорее более полной и развёрнутой историей, объясняющей что, как, почему и зачем. Исходный код доступен тут. Однажды нам поставили задачу разработать новый функционал для нашего довольно большого и древнего как помёт мамонта долгоживущего приложения, использовавшего для работы с данными обычный энтерпрайз-набор: СУБД Oracle, Hibernate, Spring Data JPA. Особенностью нашего приложения было наличие нескольких жирненьких сущностей (с десятками полей), при чём часто нам были нужны буквально считанные из них. Проблему извлечения нескольких полей из многих (минуя загрузку всей сущности и уменьшая трафик) мы решали с помощью DTO: Уверен, большинство из вас хоть раз писало что-то подобное. В нашем случае один из разработчиков предложил альтернативу — проекции. Проекция — это представление части сущности с помощью интерфейса с геттерами, соответствующими именам полей: Этот подход имеет ряд преимуществ перед привычными DTO: интерфейсы сами по себе более гибкий инструмент, чем класс. Реализовав интерфейс в указанной выше сущности и DTO можно написать сервисный метод, одинаково работающий и с сущностью, и с проекцией, и с DTO для объявления метода внутри репозитория более не требуется явное объявление конструктора, что сильно всё упрощает (особенно когда в конструкторе DTO не 1-2 аргумента, а 10), а также позволяет вообще отказаться от @Query в пользу правильно именованного метода (Спринг создаст запрос самостоятельно). Таким образом, код можно записать лаконичнее: "Идея" поддерживает проекции и даёт подсказки при написании репозиторных методов, снижая вероятность написания кривого запроса, который упадёт только  при обращении к нему. К тому же при рефакторинге интерфейса сам репозиторный метод не будет затронут (в старых версиях "Идеи" переименование/добавление/удаление полей в DTO не затрагивало их использование в JPQL-запросах, не всегда работала подсветка при неправильном порядке аргументов в конструкторе и т. п.) И мы решили попробовать реализовать новый функционал с помощью проекций. Вероятно, вы уже догадались, что в огромной бочке мёда оказалась ложка дёгтя плохой производительности. Сравним время отклика при выгрузке 1 и 100 DTO/проекций с помощью простого бенчмарка: Может показаться, что раз мы обращаемся к базе (пусть даже это и H2 на нашем хосте), то львиная доля времени тратится именно на запрос и передачу данных, следовательно цифры будут примерно одинаковыми. Однако: Итог прогона несколько обескураживает: если получение одного объекта стоит более-менее одинаково, то при получении 100 объектов возвращающий проекцию метод ManyFieldsRepository.findAllByNameUsingDto() начинает сильно проигрывать. Это контринтуитивно, ведь чаще всего на скорость отклика влияет именно запрос. Что ж, нужно расчехлять профилировщик и искать узкие места.  VisualVM ввиду его "попсовости" и некоторых врождённых пороков рассматривать не будем, начнём с JFR, благо его можно подключить напрямую из бенчмарка (вообще запуск из главного метода - это не очень "православный" подход, правильнее всё-таки пускать бенчмарк из командной строки, но в данном случае это не столь важно): Обратите внимание, опыт ставится на "восьмёрке" (это важно, далее вы поймёте почему). Прогон завершён, посмотрим на числа (нажмите на изображение для полноразмерного просмотра): Здесь ключевым является то, что лишь около 6% занимает исполнение запроса, а обработка полученных данных - без малого 80%. Разумеется, при использовании "боевой" БД на удалённом хосте соотношение сместится в сторону исполнения запроса.Развернув метод ResultProcessor.processResult() видим следующее: 77% занимает создание проекции 35% занимает конструктор ConcurrentReferenceHashMap 25% занимает ProxyFactory.getProxy() Прокачивание ProxyFactory.getProxy() описано здесь, там всё слишком просто и неинтересно. Лучше посмотрим на ConcurrentReferenceHashMap:  горячими методами являются конструктор, метод calculateShift() и конструктор вложенного класса CRHM$Segment (здесь и далее исходный код CRHM относится к версии Спринга 5.1.3). Вот они: Первое, что вызывает сомнения в достоверности данных цифр - это доля  calculateShift(). Сомнительно, что такой простой вычисляющий метод действительно занимает почти 17%. Нужна повышенная точность, а значит на помощь идёт async-profiler. Упростим бенчмарк, чтобы профилировать только создание CRHM: Добавим его в настройки нашего бенчмарка и подключим AsyncProfiler: Обратите внимание, в этом случае нам уже не нужно указывать дополнительный настройки ВМ, они добавляются автоматически в методе AsyncProfiler.addJVMOptions(). Смотрим вывод: Из профиля ожидаемо пропал calculateShift(), правда, ясности от этого не прибавилось. Вычитка кода позволяет выявить одно возможное улучшение: поскольку аргумент конструктора Segment(int) всегда один и тот же для всего цикла в конструкторе CRHM, то выражение 1 << calculateShift(initialCapacity, MAXIMUM_SEGMENT_SIZE) можно вычислить один раз. Существенного прироста это не даст. Итак, мы в тупике: исходный код горячих методов небольшой, и "на глаз" ключ к пониманию проблемы не найти. JFR и async-profiler указали лишь примерное направление поиска, а для определения конкретной строчки понадобится более точный и низкоуровневый инструмент. Подключаем o.o.j.p.LinuxPerfAsmProfiler, для наглядности я вновь делаю это в коде (доступен вариант с аргументом командной строки -prof perfasm): Обратите внимание: использование этого профилировщика требует подготовительной работы, которую я описал в одной из предыдущих статей. Также наличие 1 процесса здесь является решающим, ведь у разных процессов адреса команд с большой вероятностью будут разными, это отдельного оговорено в документации.  На первый взгляд, там чёрт ногу сломит: непонятно где и что искать. Но если присмотреться, то слева мы видим долю времени, которую занимает та или иная инструкция, правее — её адрес, имя и соответствующий ей байт-код, а ещё правее — точное указание на метод и строку в исходном Java-коде! Обратите внимание: одному байт-коду может соответствовать несколько инструкций. Дальше дело техники: листаем код и ищем горячее место, например вот это: Читатель сейчас чешет затылок и думает "М-м-м, и что я должен тут увидеть?" Признаюсь, я сам долго смотрел на это без единой внятной мысли, пока @apangin не подсказал, что перф часто смещает горячие места в выводе, это неприятное явление называется performance event skid и определено так: The skid is defined as the distance between the IP(s) that caused the issue to the IP(s) where the event is tagged Почитать об этом подробнее можно в статье Дениса Бахвалова. Для нас это означает необходимость поискать где-то вокруг горячих строк: Байт-код getfield означает чтение поля, в данном случае segments, в строке 183: Почему так много и почему исполнение не оптимизировало доступ в таком простом коде? Чтобы ответить на этот вопрос нужно поискать другие горячие инструкции: Здесь понятно не больше, чем в первом примере, смотрим вокруг: Любопытно, целых 18,29% занимает запись в поле count в строке 476: Похоже, это выстрел в десятку! Мы записываем 0 в поле целочисленного типа, которое просто по спецификации будет равно 0 (и всё это в цикле). А те самые 18,29% накручивает гадкий volatile: В обоих случаях после создания объекта значение полей будет false, однако при явном присваивании стоимость существенно выше: Запись в волатильное поле заставляет компилятор вставлять инструкции для обеспечения последовательной согласованности в соответствии с моделью памяти. В нашем случае это инструкция lock addl $0x0,(%rsp), которая является реализацией барьера StoreLoad. Запоминать эти низкоуровневые подробности реализации необязательно (они могут измениться со временем), важно понимать высокоуровневые закономерности. Выходит, ничто не мешает нам заменить выражение выражением Или всё же мешает? Этим вопросом задался Алексей Шипилёв в известной рассылке о многопоточности. Категорически советую к вдумчивому прочтению как пример построения предположений о том, как поведёт себя многопоточный код и модель памяти. Точку поставил Даг Ли: But your account is a more careful version of reasoning we've done before to conclude that there is never any reason to explicitly initialize fields to 0/0.0/false/null. Сама история тянется с 2008 (!) года: Remove unnecessary explicit initialization of volatile variables. К этой же задаче относятся AbstractMap unnecessarily initializes two volatiles to null Remove unnecessary explicit initialization of volatile variables in java.base Initializing volatile fields to default values should be optimized Remove unnecessary explicit initialization of volatile variables in security-libs code Следствием ненужности явного зануления волатильных полей является ненужность зануления объектов атомарных классов: Первый метод ожидаемо работает быстрее: Вот несколько примеров из жизни: раз, два, три. Теперь вернёмся к ассемблеру. Похожий код находим ниже: Признаки те же, а виновником является java.lang.ref.ReferenceQueue: Поскольку это класс из JDK, мы ничего не можем с ним поделать (в Java 9+ это исправлено). Едем дальше. В самом конце ассемблерного кода находим следующий участок: Здесь 8% занимает всё тот же lock addl $0x0,(%rsp), соответствующий записи в волатильное поле в конструкторе Segment: volatile здесь нужен для правильной работы в многопоточной среде, так что пропускаем, а вот к 10,51%, соответствующим байт-коду arraylength в строке 488, стоит присмотреться: Поскольку мы читаем из волатильного поля, то чтение стоит дороже обычного. Решением здесь может стать создание локальной переменной, в которую будет записан массив, возвращаемый из createReferenceArray, и последующее присваивание и чтение из неё. Мне кажется, что здесь мы имеем дело с ошибкой профайлера, исполнение наверняка смогло бы оптимизировать запись и чтение из одного и того же волатильного поля, указанные 10,51% относятся к чтению/записи массива в цикле конструктора CRHM. К тому же я заметил, что ассемблерный профиль может отличаться от ядра к ядру. Наличие волатильных доступов внутри цикла, а также доступ к массиву через обращение к полю сделали его (доступ) довольно дорогим. Именно к доступу относится самый первый горячий участок ассемблера: Что с этим делать подробно описано в указанной в самом начале статье, так что подробно останавливаться на этом я не буду. Ничто не мешает нам прогнать этот же бенчмарк на новых версиях Java, например, на 11/17. И тут нас ждёт нежданчик: всё будет работать как прежде, но результаты немного отличаются. Полный вывод находится тут, и пробежавшись по нему неожиданно обнаруживаем, что из него исчезло всякое упоминание putfield count. Впервые осознав это я подумал, что оптимизирующий компилятор в старших версиях стал настолько крутым, что смог распознать и выбросить лишнее присваивание волатильному полю значения по умолчанию. Но всё же решил перепроверить и поискать lock addl $0x0. И эта инструкция никуда не делась: Она как и прежде выполняется после вызова конструктора ReentrantLock, дочерним классом которого является ConcurrentReferenceHashMap$Segment. Главная трудность в том, что если разработчик начнёт профилировать на Java 11 и выше, он просто-напросто не увидит указания на конкретную строку в исходном Java-коде! Исчерпывающего понимания происходящего у меня не было, так что я задал вопрос. Выяснилось, что оптимизации и перестановки в исполнении JIT-а сломали соотнесение скомпилированного кода и исходников, впрочем, выход есть: выполнив прогон с флагом -XX:MaxInlineLevel=0 (отключает вклеивание) получаем нужное: Очевидно, что это так себе решение, ведь в боевом коде глубина вклеивания будет отличаться от используемой в опытах. Кажется, нужно писать в рассылку hotspot-compiler-dev. Ответ не заставил себя долго ждать: There's never been any guarantee that debuginfo will be complete after transformations. Optimization rewrites things to such an extent that it's not really possible anyway: operations are reorganized and combined in such a way that the relationship between incoming bytecode and generated code is not 1:1. Краткий перевод: вам никто ничего не обещал, но вы держитесь там! Всего доброго и отличного профилирования :). Возможно, где-то в методологии исследования была допущена ошибка, и я подкладывал собранный из исходников Java 8 hsdis в процесс, запущенный на Java 11. Сегодня это кажется мне наиболее вероятным, ибо когда я собирал hsdis из исходников JDK 17 с самым свежим binutils и пользовался локальной сборкой для профилирования маппинг работал идеально. Если вы столкнулись с похожей проблемой — попробуйте взять исходники стабильной версии вашего JDK (и запускать приложение на нём), собрать на нём hsdis для вашей платформы и использовать. Указанный способ обхода данного ограничения (-XX:MaxInlineLevel=0) категорически не советую использовать в качестве единственного средства диагностики, потому что отключение вклеивания может сильно исказить профиль. Доказать это можно на простом примере: Если запустить этот бенчмарк на старших версиях (например, 17), то метод calculateNoInlining() проиграет с чудовищным отрывом как по времени, так и по потреблению памяти: Профиль, разумеется, будет сильно искажён. К счастью для меня, я не ограничился одним-двумя прогонами, и запустив в очередной раз бенчмарк c Java 11 внезапно обнаружил в выводе искомый putfield count! Тут я окончательно растерялся. После несколько дополнительных прогонов картинка немного прояснилась: указание на putfield count при запуске в обычном режиме (иными словами без дополнительных настроек ВМ) появлялось в выводе случайным образом. Тогда было принято решение собрать статистику: В этом скрипте мы выполняем 100 прогонов и вывод каждого пишем в отдельный файл. После переходим в папку с файлами и считаем количество вхождений искомой строки: У меня получилось так: Проще говоря в 24 случаях из 100 компилятор сохранил указание на putfield count. Не очень-то обнадёживает, правда? Получается, никогда нельзя полагаться на один прогон! Ведь там часть данных может потеряться и вы тупо не увидите ссылку на горячее место в исходном Java-коде. Поэтому советую выполнять 10-20 прогонов с выводом в файл и просматривать каждый отдельно. Итак, чего же мы добились нашими измененниям (раз, два): Здесь J8 и J11 — это Java 8/11, а SB — Spring Boot соответствующей версии. в сложный случаях, когда неясно, куда именно смотреть, не бойтесь нырнуть глубже и посмотреть ассемблер помните, что все профилировщики допускают погрешность, см. прекрасные доклады Nitsan Wakart - Profilers are lying hobbitses и Андрей Паньгин - Искусство Java профилирования не стесняйтесь задавать вопросы — объясняя его другим вы сами лучше понимаете стоящую перед вами задачу выпиливайте присваивание значений по-умолчанию волатильным полям в ваших приложениях, это даст некоторый прирост и ничего не сломает (а если всё же сломает, то обязательно напишите об этом в http://cs.oswego.edu/pipermail/concurrency-interest, это станет сенсацией). Также ставьте палец вверх этим задачам:https://youtrack.jetbrains.com/issue/IDEA-243752https://youtrack.jetbrains.com/issue/IDEA-251885Так на них быстрее обратят внимание ;) пользуйтесь флагами виртуальной машины при прогонах, они помогут вам сузить область поиска. Например, если вы грешите на компилятор — пробуйте запуск с -Xint / -XX:TieredStopAtLevel=1 или уже упомянутый -XX:MaxInlineLevel=0, однако помните, что они могут как помочь, так и запутать. Пробуйте разные сочетания для получения достоверных результатов. выполняйте множество прогонов и собирайте статистику REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial experiments, perform baseline and negative tests that provide experimental control, make sure the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts. Do not assume the numbers tell you what you want them to tell. На этом всё, надеюсь, заметка была интересной и полезной вам. До новых встреч :)  Разработчик