В недавней статье про производительность Java разгорелась дискуссия на тему измерения производительности. Глядя на неё, с грустью приходится сознавать, что многие люди до сих пор не понимают, насколько сложно правильно измерить время выполнения того или иного кода. Кроме того, люди вообще не привыкли, что один и тот же код в разных условиях может выполняться существенно разное время. К примеру, вот одно из мнений:


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

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


Скажем, мы увидели Java 8 Stream API и хотим проверить, насколько быстро оно работает для простой математики. Например, для простоты возьмём стрим из целых чисел от 0 до 99999, возведём каждое в квадрат с помощью операции map, ну и на этом всё, больше ничего делать не будем. Мы же просто производительность замерить хотим, да? Но даже беглого просмотра API хватит, чтобы увидеть, что стримы ленивы и IntStream.range(0, 100_000).map(x -> x * x) по факту ничего не выполнит. Поэтому мы добавим терминальную операцию forEach, которая как-нибудь использует наш результат. Например, увеличит его на единичку. В итоге мы получим вот такой тест:


static void test() {
    IntStream.range(0, 100_000).map(x -> x * x).forEach(x -> x++);
}

Отлично. Как измерить, сколько он работает? Все знают: взять время в начале, время в конце и посчитать разницу! Добавим метод, который производит замер времени и возвращает результат в наносекундах:


static long measure() {
    long start = System.nanoTime();
    test();
    long end = System.nanoTime();
    return end - start;
}

Ну а теперь просто выведем результат. На моём не самом быстром Core i7 и Open JDK 8u91 64bit я в разных запусках получаю число примерно в районе от 50 до 65 миллионов наносекунд. То есть 50-65 миллисекунд. Сто тысяч возведений в квадрат за 50 миллисекунд? Это чудовищно! Это всего два миллиона раз в секунду. Двадцать пять лет назад компы и то быстрее в квадрат возводили. Java безбожно тормозит! Или нет?


На самом деле первое использование лямбд и Stream API в приложении всегда добавит задержку на 50-70 мс на современных компьютерах. Ведь за это время надо сделать немало вещей:


  • Загрузить классы для генерации рантайм-представлений лямбд (см. LambdaMetafactory) и всё, что с ними связано.
  • Загрузить классы самого Stream API (их там немало)
  • Для лямбд, что используются в нашем коде (в нашем случае одна) и в коде Stream API (а вот там их немало) сгенерировать рантайм-представление.
  • JIT-компилировать это всё добро хотя бы как-нибудь.

Всё это требует немало времени и на самом деле даже удивительно, что удаётся уложиться в 50 мс. Но всё это нужно ровно один раз.


Лирическое отступление


Вообще с наличием динамической загрузки и кэширования чего бы то ни было становится очень сложно понять, что же мы измерили. Это касается не только Java. Простой библиотечный вызов может инициировать подгрузку с жёсткого диска и инициализацию разделяемой библиотеки (а представьте, что жёсткий диск ещё и в спящий режим ушёл). В результате вызов может занять гораздо больше времени. Париться по этому поводу или нет? Иногда приходится. Например, во времена Windows 95 загрузка разделяемой библиотеки OLE32.DLL занимала существенное время и в тормозах бы объявили первую программу, которая бы попыталась загрузить OLE32. Это вынуждало разработчиков по возможности не загружать OLE32 как можно дольше, чтобы виноватыми стали другие программы. Кое-где в других библиотеках даже реализованы функции, дублирующие некоторые функции OLE32, как раз с целью избежать загрузки OLE32. Подробнее об этой истории читайте у Рэймонда Чена.

Итак, мы поняли, что наш бенчмарк супермедленный, потому что в процессе делается много вещей, которые надо сделать ровно один раз после загрузки. Если наша программа планирует работать больше секунды, скорее всего нас это сильно не волнует. Поэтому давайте "прогреем JVM" — произведём этот замер 100 тысяч раз и выведем результат последнего замера:


for (int i = 100000; i >= 0; i--) {
    long res = measure(); 
    if(i == 0)
        System.out.println(res);
}

Эта программа завершается быстрее, чем за секунду, и печатает на моей машине 70-90 наносекунд. Это супер! Значит, на одно возведение в квадрат приходится 0.7-0.9 пикосекунд? Java возводит в квадрат больше триллиона раз в секунду? Java супербыстрая! Или нет?


Уже на второй итерации многое из вышеприведённого списка выполнится и процесс ускорится раз в 100. Дальше JIT-компилятор будет постепенно докомпилировать разные куски кода (его внутри Stream API немало), собирая профили выполнения и оптимизируя всё больше. В конечном итоге JIT оказывается достаточно умён, чтобы заинлайнить всю цепочку лямбд и понять, что результат умножения нигде не используется. Наивная попытка использовать его через инкремент JIT-компилятор не обманула: побочного эффекта у этой операции всё равно нет. JIT-компилятору не хватило сил выкосить вообще весь стрим целиком, но он смог выкосить внутренний цикл, фактически сделав производительность теста не зависящей от количества итераций (замените IntStream.range(0, 100_000) на IntStream.range(0, 1_000_000) — результат будет тот же).


Кстати, на таких временах оказывается существенным время выполнения и гранулярность nanoTime(). Даже на одинаковом железе но в разных OS вы можете получить существенно разный ответ. Подробнее об этом — у Алексея Шипилёва.


Итак, мы написали "самый примитивный бенчмарк". Сперва он оказался супермедленным, а после небольшой доработки — супербыстрым, почти в миллион раз быстрее. Мы хотели измерить, как быстро с помощью Stream API выполняется возведение в квадрат. Но в первом тесте эта математическая операция потонула в море других операций, а во втором тесте просто не выполнялась. Опасайтесь делать поспешные выводы.


Где же правда? Правда в том, что этот тест не имеет ничего общего с реальностью. Он не производит видимых эффектов в вашей программе, то есть фактически он ничего не делает. В реальности вы редко пишете код, который ничего не делает, и уж конечно, он вряд ли приносит вам деньги (хотя бывают и исключения). Пытаться ответить на вопрос, сколько времени на самом деле выполняется возведение в квадрат внутри Stream API, вообще малоосмысленно: это очень простая операция и в зависимости от окружающего кода JIT-компилятор может очень по-разному скомпилировать цикл с умножением. Помните, что производительность не аддитивна: если A выполняется x секунд, а B выполняется y секунд, то совсем не факт, что выполнение A и B займёт x+y секунд. Может оказаться абсолютно не так.


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


На прошлогоднем Joker'е я рассматривал несколько более интересный пример замера производительности Stream API и копнул глубже, что там происходит. Ну и обязательная ссылка на JMH: он поможет не наступать на простые грабли при измерении производительности JVM-языков. Хотя, конечно, даже JMH волшебным образом все ваши проблемы не решит: думать всё равно придётся.

Поделиться с друзьями
-->

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


  1. struvv
    07.08.2016 12:38

    Существует ли какой-то набор правил, вроде чек листа — что делать нужно и чего делать нельзя, чтобы в большинстве случаев не наступить на грабли при замере производительности участка кода?


    1. vladimir_dolzhenko
      07.08.2016 12:45
      +7

      Не зря приводится JMH — чтобы начать очень и очень рекомендуется ознакомиться с jmh примерами.


    1. apangin
      07.08.2016 17:12
      +9

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

      Вот знаменитый вопрос на StackOverflow. Или ещё полезный пост с примерами.

      По моим наблюдениям самые частые ошибки такие.

      • Один большой метод, в котором замеряют все алгоритмы. Это в корне неверно. Единица JIT компиляции — метод. Чем длиннее метод, тем сложнее его оптимизировать. Скорее всего, код будет адаптирован лишь под один из алгоритмов, по которому успела собраться run-time статистика. Разные алгоритмы надо запускать в отдельных JVM.
         
      • Весь бенчмарк — один длинный цикл в main(). Таким образом, вместо полноценно скомпилированного метода тестируют OSR заглушку. Один прогон бенчмарка следует поместить в отдельный метод, который запускается несколько раз.
         
      • Замеры надо проводить в устойчивом состоянии, когда все классы уже загружены, все компиляции-рекомпиляции уже прошли и т. д. Нет единого «золотого» числа итераций, после которого приложение заведомо будет работать с максимальной скоростью. Для одних тестов это пара секунд, для других — несколько минут.
         
      • «Прогревать» следует ровно тот код, который измеряется. Иначе Profile Pollution может всё испортить.
         
      • Бенчмарк должен иметь эффект, чтобы JIT не выкинул код, который что-то вычисляет, но нигде результаты вычислений не использует.
         
      • Самые честные замеры — на свежей 64-битной JDK в режиме Tiered или C2. Показатели 32-битной JVM и 64-битной, «клиентского» компилятора и «серверного» могут отличаться на порядок.
         
      • Убедитесь, что памяти достаточно, и что GC не оказывает влияния на производительность.
         
      • Минимизируйте сторонние эффекты. Избегайте лишних println-ов, nanoTime-ов и прочих операций, не имеющих отношения к измеряемому коду.
         


  1. maxwin
    07.08.2016 12:38
    +2

    Из статьи можно сделать только вывод — давайте не будем измерять производительность в тестах время выполнения которого мало.


    1. 23derevo
      07.08.2016 15:23

      Это недалеко от истины. Если программа запускается раз в сутки, то, как правило, глубоко пофиг, сколько она выполняется — 300 миллисекунд или 600.


      1. xdenser
        07.08.2016 16:12

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


        1. NLO
          08.08.2016 09:07

          НЛО прилетело и опубликовало эту надпись здесь


          1. lany
            08.08.2016 09:19
            +2

            Так этим и занимается JIT прямо на боевой системе: что часто вызывается, то оптимизирует (компилируя более продвинутым компилятором C2). Что редко вызывается, то не сильно оптимизирует (компилируя более быстрым компилятором C1).


            1. NLO
              08.08.2016 10:11

              НЛО прилетело и опубликовало эту надпись здесь


  1. Mendel
    07.08.2016 15:03
    +9

    Если разница будет существенной (скажем, на порядок) — то скорее всего и на пользовательской машине все будет примерно также.

    Ждал про то как меняется производительность от контекста, и что с этим делать.

    Перед глазами сразу встала картина конца девяностых.
    Мой командир (я учился в полувоенном вузе) попросил меня сделать ему программу для автоматического составления графика нарядов. Но непременно в Экселе.
    На тот момент я ничего лучше не придумал, чем сделать это всё на рекурсивных ссылках. Работало медленно, но в целом скорость была приемлима.
    Через месяц я таки узнал что есть такое понятие как VBA и решил переписать всё по человечески, процедурой.
    Полдня работы, и новая версия готова.
    отработала она у меня на порядок, если не на два быстрее.
    Приношу к командиру, а она у него работает медленнее чем прошлая версия.
    Не помню в чем конкретно был момент, давно это было, да и говнокода своего уже не вспомнить. Но помню что разгадка была в том, что у одного из компов был полноценный сопроцессор, вроде даже ММХ, а второй был стареньким клоном 486, но зато памяти было аж 32мб, в отличии от 16мб на другом.
    Для меня это тогда было настоящим откровением. Я примерно догадывался что профиль железа может несколько исказить пропорции в производительности, но чтобы вот так, прямо противоположным образом, то не думал…


  1. Diaskhan
    07.08.2016 16:10
    +1

    Сначала мы пишем на Ассемблере, и говорим что сложно писать большие сложные программы, потом придумываем С.
    Потом мы пишем на СИ, а потом говорим что тяжело без ООП, Придумываем С++.
    Потом мы пишем на С++ и понимаем что указатели зло. Потом придумываем Java и С#, потом говорим что они очень медленные ))
    Каждый язык что-то чинит )) А перфоманс лишь последствие того что мы решили починить в других языках ))


    Недавно посмотрел https://youtu.be/_79KfX-3sQc?t=70 очень позновательно


    1. Jogger
      08.08.2016 09:25
      -2

      Да всё просто — чем выше уровень языка — тем медленнее конечная программа, и тем быстрее разработка. Заказчику не важно, насколько медленно работает программа (если её покупают), зато ему важно ускорить разработку, чтобы меньше платить за человеко-часы. Вот и получается, от нового железа ждут, что оно будет работать быстрее, а от нового софта, почему-то, что он будет работать медленнее. Если когда-нибудь закон Мура перестанет таки действовать — нас догонит этот эффект, и это будет очень больно. Но до тех маркетологам удаётся провернуть трюк «Купите новый компьютер — не будет тормозить».


    1. m1ld
      08.08.2016 09:28
      +3

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


  1. xdenser
    07.08.2016 16:16
    +1

    Загрузить классы самого Stream API (их там немало)

    что-то сомневаюсь, что он прям весь Stream API загружает.
    Но согласен, что загрузка классов в Java может занимать существенное время.


    1. lany
      07.08.2016 16:37
      +4

      Интересный вопрос и это легко проверить. Однократный вызов метода test() на свежезапущенной JVM загружает 206 классов. Из них:


      • 40 шт — java.lang.invoke (всё что связано с поддержкой MethodHandle и генерацией ламбда-форм, плюс LambdaMetafactory)
      • 14 шт — библиотека ASM (генерация байткода, благодаря которой работает LambdaMetafactory)
      • 27 шт — классы/интерфейсы java.util.stream и сплитераторы
      • 4 шт — интерфейсы из java.util.function
      • 44 шт — прочие классы стандартного рантайма (rt.jar), многие вроде ArrayList всё равно бы загрузились в реальном приложении позже, но измерили их загрузку мы именно сейчас.
      • 76 шт — сгенерированные классы для лямбда-форм (если кому интересно, зачем это, смотрите Владимира Иванова)
      • 2 шт — рантайм-представления двух лямбд из теста.

      Как оказалось, лямбд внутри Stream API при этой операции не генерируется ни одной, тут у меня было ложное представление! Но так или иначе классов немало.


      • Edit: забыл написать, проверял с помощью java -verbose:class.


  1. handicraftsman
    07.08.2016 17:18

    <ancient_iron>Core i7? Не самом быстром? Автор явно не видел моего Pentium Dual-Core E5500</ancient_iron>


    1. lany
      07.08.2016 17:20
      +1

      Я имел в виду, на не самом быстром из существующих Core i7. Почему не видел? Не так давно продал как раз такой, он мне много лет служил верой и правдой :-)


      1. handicraftsman
        07.08.2016 17:25

        А мой мне до сих пор служит. Даже ядро не раз собрал :)


  1. vanxant
    07.08.2016 20:23
    +1

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


    1. apangin
      08.08.2016 00:20
      +6

      Фокус в том, что в реальных программах ничего прогревать не нужно. Это делается само собой, причём статистика собирается на полезной нагрузке. Приложения могут работать часами, днями, месяцами… А, вот, в микробенчмарках такое поведение воспроизвести сложно. Хочется померить маленький кусок кода и очень быстро. Отсюда и приёмчики с искусственным прогревом и т. п.

      в реальной программе тестируемый метод будет вызываться, вероятно, относительно редко
      Редко вызываемый код почти никогда не будет проблемой. Ну, отработает он за 10мс. Ну, за 100… Человек разницы даже не заметит. А если код работает долго — значит, там, есть циклы, есть повторения… Те самые тысячи и миллионы прогонов по одним и тем же инструкциям, которые JIT и призван оптимизировать.


      1. vanxant
        08.08.2016 04:04
        -3

        Редко вызываемый код почти никогда не будет проблемой

        Ну конечно.
        Java — это, обычно, махровый энтерпрайз.
        То есть имеются тысячи планктона, которые чего-то там в систему вводят.
        И есть биг-босс, который раз в неделю или раз в квартал смотрит отчёт.
        И вот ему этот самый отчёт нужно предоставить очень быстро. Иначе он сочтёт, что ваша программа «тормозит» и вообще «не очень».
        Трюк в том, что именно этот биг-босс принимает решение о финансировании. А не рядовые клерки.
        Ну и? Ваши действия?


        1. lany
          08.08.2016 05:05
          +8

          У вас в голове неправильный масштаб времени происходящего. Смотрите: если метод выполняется один раз и не имеет блокировок (будь то mutex или i/o, или другой синхронный системный вызов), он либо выполняется не больше миллисекунды (даже в интерпретируемом режиме), либо вызывает другие методы в цикле, либо содержит большой цикл, который не вызывает других методов. В первом случае даже бигбоссу плевать. Во втором уже через несколько итераций цикла все вызываемые методы будут JIT-компилированы и код уже разгонится до весьма приличной скорости (а потом дооптимизируется до максимальной). В третьем (который очень редкий в реальной жизни) спасает механизм OSR. В любом случае меньше чем за секунду даже гигантский объём кода будет весь приведён в боевое состояние, поэтому бигбоссу плевать на JIT. Тормоза в Java кроются совсем в других местах (как раз i/o, избыточная синхронизация, сборка мусора, если программа чрезмерно гадит, и т. д.)


        1. xhumanoid
          09.08.2016 00:18
          +3

          наверное стоит начать махровый энтерпрайз с высокочастотного трейдинга

          потом можно продолжить поисковым движком elasticsearch (напомните мне схожий по возможностям продукт не на java, вроде sphinx что-то умеет, правда количество упоминаний и инсталяций в разы меньше)

          дальше продолжаем быстрой распределенной очередью, чтобы заменить kafka (zeromq это конструктор, а не готовая распределенная персистентная очередь)

          так как нам нужно распределенно считать, то следом в очередь spark, storm (даже у последователя heronосновной объем кода это java), gearpump

          можно еще упомянуть hbase (бедные Airbnb и Xiaomi не знаю, что нельзя его было использовать) или его сородича accumulo (писалась по заказу АНБ с требованием жестких ACL вплоть до отдельной ячейки)

          вот так сидят все и формочки клепают


          1. lany
            09.08.2016 02:37
            +2

            Ещё в копилку классных проектов на Java — Apache Cassandra


  1. bigfatbrowncat
    08.08.2016 10:23
    -8

    Лучший бенчмарк — это ваша программа.

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

    Если в переписанном куске уже стало намного быстрее, значит однозначно меняйте язык для этого куска. Если нет, то смотрите внимательно, что можно оптимизировать. Например, если переписывать с Java на C, то можно применить векторизацию… В общем, думайте.

    Но Python почти всегда намного медленнее, чем Java. И это — факт. Трудно придумать тест, на котором будет наоборот.


    1. Optik
      08.08.2016 12:05
      +2

      Одна из самых больших глупостей — менять систему на другую без диагностики проблем.


      1. bigfatbrowncat
        08.08.2016 12:37
        -2

        Речь идет не о проблемах, а о выборе языка под задачу.


        1. Optik
          08.08.2016 13:44
          +1

          Зачем вообще тогда это делать, если нет проблемы?


  1. apangin
    08.08.2016 16:26
    +6

    Вот отличный канонический пример на тему «Java тормозит».

    Чувак пишет бенчмарк, замеряющий скорость работы с memory-mapped файлом на Java и на C.
    Запускает… Программа на Java работает в 500 раз медленнее! Какой вывод? Дрянь эта ваша Java!

    Но надо отдать должное автору, он не стал сразу ругать Java, а обратился на StackOverflow с вопросом «почему?» Можно было пойти дальше и проанализировать самому, хотя бы с помощью профайлера. И тут становится понятно, что Java-то не виновата. Это программист дёргает на каждый байтик file.size(). А если переписать тест грамотно, то окажется, что разница в скорости вообще копеечная.


  1. PsyHaSTe
    09.08.2016 14:55
    +3

    Эх, вот смотрю на эти вкусности JITа и слюни пускаю, что шарповый умеет разве что вместо интерфейсного метода вызывать его на инстансе, избегая упаковки в случае вызова на структуре…