Привет, Хабр! Замер производительности кода — не самое простое упражнение для разработчика. Приходится решать кучу сложностей: разбираться с методом, создавать правильные условия. И всё равно можно получить результат с погрешностью, потому что любой метод «не бесплатный» и требует ресурсов процессора.

Меня зовут Александр Певненко, я Java-разработчик в СберТехе. Вместе с командой мы развиваем Platform V DataSpace. Это облачный сервис, который упрощает и ускоряет разработку приложений, используя концепцию Backend-as-a-Service (BaaS) для хранения и управления данными. Я расскажу про наш способ замера производительности кода с помощью бенчмарков. Рассматривать метод будем на примере оптимизации кода в Platform V Dataspace, которая помогла сократить время вызова метода в 16 раз.

В статье я буду пользоваться языком Java, Python для построения графиков и набором библиотек JMH — они также адаптированы для Kotlin, Scala и т.д. 

Зачем вообще измерять производительность кода…

…если вроде бы всё и так работает нормально? На самом деле, помимо очевидных выгод в виде скорости или стабильности есть ещё одна причина: оптимизацию можно рассматривать как важную часть культуры CI/CD. В небольших проектах эти параметры могут и не быть критичными. Зато практически все Agile-команды сегодня работают с DevOps-практиками и осознают ценность непрерывной поставки.

Пока проект «молодой», развёртывание и тестирование может быть относительно простым. Но как только модуль вырастает за рамки агента, выполняющего сборку, или на локальную сборку тестов начинает уходить по два часа, в головы разработчиков приходят светлые мысли: «А может, стоит уменьшить код? Заняться производительностью? Вдруг какие-то методы раздуты до небывалых размеров?» Именно здесь на помощь приходит оптимизация кода. Она становится частью непрерывной интеграции и следующим шагом в развитии DevOps.

В нашем случае важны все аспекты. Скорость — потому что Platform V DataSpace поставляется в составе облачной платформы Platform V, которая лежит в основе большинства продуктов Сбера. Оптимизация DevOps — из-за того, что продукт быстро растёт и важно обеспечивать непрерывную поставку функциональности в промышленную эксплуатацию.

Как замерить производительность: методы и сложности

Вёрнемся к нашему примеру. В одном из проектов Platform V DataSpace вызов метода занимал очень много времени. При этом алгоритм построен так, что избежать сложностей и вызывать метод «малой кровью» не получалось. 

При пристальном рассмотрении выяснилось, что код вызывал один и тот же метод несколько раз, из-за чего растягивался timestamp. Для оптимизации нужно было оценить точную длительность работы метода. 

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

Бенчмарк — тест для оценки длительности работы метода. Он хорош тем, что позволяет замерять скорость работы алгоритма с учетом всех внешних факторов и на реальном оборудовании. В основе любого бенчмарка лежит системное время работы процессора и расчёт длительности выполнения блока кода. Чаще всего для этого используется метод System.nanoTime(), у которого, как оказалось, тоже есть свои особенности. Дело в том, что сам по себе метод системного взятия времени неизбежно даёт погрешность, даже если мы сделаем так: 

Void checkTime(){
Long oldTime = System.nanoTime();
work();
return System.nanoTime() – oldTime;
}

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

Возникают вопросы:

  1. Получается, мы никак не избежим погрешности при вычислении «стоимости» метода work()

  2. Что считать baseline и на основе чего его строить? 

  3. Как «сжечь» время при замере? 

Но после подробного изучения метода оказалось: нет, погрешности избежать можно. Для этого есть несколько вариантов. Самый простой — использовать библиотеки JMH для Java. 

JMH: чем так хороши и почему не подошли нам

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

  • узнаём латентность — время на вызов System.nanoTime(),

  • измеряем гранулярность метода — разрешающую способность, минимальную ненулевую разницу между вызовами метода System.nanoTime()

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

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

Тестирование оптимальности кода в «боевых условиях»

Ещё один способ избежать погрешности — измерить «стоимость» System.nanoTime(). Это позволит нам получить значение, которое будет коррелировать с длительностью исполнения метода взятия времени. 

Для решения проблемы с повторным вызовом метода я подготовил самописный бенчмарк и попробовал вычесть baseline до оптимизации и после. Затем сравнил, сколько времени требовалось на исполнение кода до и после доработок. Чтобы работать было проще, собрал графический интерфейс для визуализации на Python. Вот что получилось:

Набор данных до оптимизации.
Набор данных до оптимизации.
Визуализация набора данных. 
Визуализация набора данных. 

Аналогичные измерения после оптимизации давали видимый результат. На графике ниже — измерения до оптимизации (верхние линии) и после (нижние):

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

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

Выводы

Измерять производительность кода стоит хотя бы из любопытства, а лучше для того, чтобы повысить скорость продукта и упростить развёртывание. Бенчмарки — отличное решение для этого. В нашем случае самописные бенчмарки помогли серьёзно сократить длительность вызова System.nanoTime(). Сейчас работаем над тем, чтобы в большинстве проектов тестировать оптимальность всей системы ради снижения количества потенциальных проблем.

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

Комментарии (12)


  1. PqDn
    00.00.0000 00:00
    +16

    А содержание метода, который оптимизировали, большая тайна?


    1. ris58h
      00.00.0000 00:00
      -1

      У нас есть такие приборы!


  1. capt_Rimmer
    00.00.0000 00:00

    /бубнит Лучше бы нормальные bitrix модули для СБП и интернет-эквайринга своего же сделали...


    1. Antharas
      00.00.0000 00:00

      Работает, не трогай! (Сбер)


  1. akhmelev
    00.00.0000 00:00
    +8

    Замеряй и ускоряй: как мы сократили время вызова метода в Java-коде в 16 раз - э... действительно: как?
    даже нет (голосом Коляна): да как так то?
    ChatGPT что ли?

    Предлагаю переименовать, пока не поздно:

    Суровые трудовые будни или для чего в Сбере применяют System.nanotime() - вот так было бы пожалуй информативнее.


  1. gazkom
    00.00.0000 00:00
    +5

    В одном из проектов Platform V DataSpace вызов метода занимал очень много времени.

    Что занимало много времени? Вызов? Может метод долго работал, а не вызывался долго?

    Для оптимизации нужно было оценить точную длительность работы метода. 

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


  1. PaulIsh
    00.00.0000 00:00

    Можно подключить трассировки, например, elastic APM и там увидеть все задержки в боевых условиях вместо изобретения своих методов замера и графиков.


  1. Vest
    00.00.0000 00:00
    +1

    Что мешало включить профайлинг во время выполнения запроса и посмотреть самый дорогой вызов? Или хотя бы здесь выложить flame-graph с вызовами.

    Что хоть тормозило, наносекунды? А зачем такая точность, особенно, если я понимаю, тормозит. У вас же там секунды (а не микросекунды) ожиданий.


    1. slonopotamus
      00.00.0000 00:00
      +1

      Timestamp у них растягивался, написано же.


      1. Vest
        00.00.0000 00:00
        +1

        Я читал тот участок, но не понял как растягивался. Вдоль или в ширину? А вы поняли?


  1. schernolyas
    00.00.0000 00:00

    Нда ... Просто Hotspot сгенерил нативную версию метода и выполнил ее быстрее. Поищите старый доклад Валерия Иванова из Oracle про динамическую компиляцию.


  1. 23derevo
    00.00.0000 00:00
    +1

    Из статьи непонятно вообще ничего. Что вы оптимизировали, как, зачем, почему? Что там за требования к процессу такие, что «вместо» JMH вам пришлось тащить что-то ещё? И зачем вообще JMH, если вы какую-то энрепрайз штуку измеряете? Это же тул для _микро_бенчмарков.