Супермедленный и супербыстрый бенчмарк

    В недавней статье про производительность 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 (их там немало)
    • Для лямбд, что используются в нашем коде (в нашем случае две) сгенерировать рантайм-представление.
    • 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 волшебным образом все ваши проблемы не решит: думать всё равно придётся.

    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 47

      0
      Существует ли какой-то набор правил, вроде чек листа — что делать нужно и чего делать нельзя, чтобы в большинстве случаев не наступить на грабли при замере производительности участка кода?
        +7
        Не зря приводится JMH — чтобы начать очень и очень рекомендуется ознакомиться с jmh примерами.
          +9
          Да, есть общие правила, применимые почти ко всем микробенчмаркам. Хотя даже если следовать им, не всегда удаётся измерить правильно. Поэтому любой бенчмарк без должного анализа результатов не имеет смысла.

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

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

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

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

                  • UFO just landed and posted this here
            +9
            Если разница будет существенной (скажем, на порядок) — то скорее всего и на пользовательской машине все будет примерно также.

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

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

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


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

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

                  что-то сомневаюсь, что он прям весь Stream API загружает.
                  Но согласен, что загрузка классов в Java может занимать существенное время.
                    +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.
                    0
                    <ancient_iron>Core i7? Не самом быстром? Автор явно не видел моего Pentium Dual-Core E5500</ancient_iron>
                      +1

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

                        0
                        А мой мне до сих пор служит. Даже ядро не раз собрал :)
                      +1
                      Тут есть философский вопрос насчёт прогрева.
                      Ну то есть запустить 1 прогон, чтобы точно загрузились классы — с этим никто не спорит.
                      Но вот давать jit компилятору набрать статистику и оптимизировать код — это уже большой вопрос.
                      Во-первых, тестовые данные это обычно примитивная лажа с потолка. Ну, условно, в примере из статьи берутся числа от 1 до 100000, причём подряд, но в реальных задачах будут те же 100000 чисел, но каких-нибудь реальных. И статистика и, соответственно, оптимизации будут совсем другими.
                      Второе — в программах на Java довольно редко один и тот же алгоритм гоняется в цикле тысячи раз. Такие алгоритмы это обычно какой-нибудь матан, и его чаще всего не на Java считают.
                      Соответственно в реальной программе тестируемый метод будет вызываться, вероятно, относительно редко, единицы-десятки раз за прогон. Т.е. на практике не будет статистики, не будет мегамахровой оптимизации, и как раз важно время второго-третьего прогона, а не миллионного.
                        +6
                        Фокус в том, что в реальных программах ничего прогревать не нужно. Это делается само собой, причём статистика собирается на полезной нагрузке. Приложения могут работать часами, днями, месяцами… А, вот, в микробенчмарках такое поведение воспроизвести сложно. Хочется померить маленький кусок кода и очень быстро. Отсюда и приёмчики с искусственным прогревом и т. п.

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

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

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

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

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

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

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

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

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

                                +2

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

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

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

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

                          Но Python почти всегда намного медленнее, чем Java. И это — факт. Трудно придумать тест, на котором будет наоборот.
                            +2
                            Одна из самых больших глупостей — менять систему на другую без диагностики проблем.
                              –2
                              Речь идет не о проблемах, а о выборе языка под задачу.
                                +1
                                Зачем вообще тогда это делать, если нет проблемы?
                                0
                                А кто сказал, что без диагностики?

                                Искусственные бенчмарки почти никогда не дают верного результата, потому что меряют не то, что нужно конкретно вам. Единственный правильный бенчмарк — это профилирование вашей собственной программы. Написали — тормозит — запустили профилировщик — нашли узкие места — поправили. Обычно «поправили» — и есть смена языка. Например, с Java на C+JNI.

                                Просто, иногда (в не слишком заковыристых случаях) для профилирования дополнительных интрументов не нужно — пары ловких Unit-тестов достаточно, чтобы оценить производительность.

                                Смена языка на основе левых бенчмарков — вот это реально глупость.
                                  +1

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


                                  Обычно «поправили» — и есть смена языка.

                                  Мой опыт говорит, что большинство проблем с производительностью на Java решается оптимизацией Java-кода, а не впиливанием C+JNI. Очень редко впиливать C+JNI оправданно. У вас другой опыт? Можете рассказать пару историй успеха, когда вам пришось впилить C+JNI?

                                    0
                                    Физическое моделирование или обработка изображений с необходимостью ворочать полугигабайтными массивами данных. Сборщик мусора не справляется.

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

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

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

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

                                Сравним:

                                public class ThousandGB {
                                	public static void main(String... args) {
                                		
                                		int[] arr;
                                		
                                		for (int i = 0; i < 100000; i++) {
                                			arr = new int[1024 * 1024];
                                		}
                                	
                                	}
                                }
                                


                                и аналогичный на C++
                                int main() {
                                
                                	int* arr;
                                	
                                	for (int i = 0; i < 100000; i++) {
                                		arr = new int[1024 * 1024];
                                		delete [] arr;
                                	}
                                
                                }
                                


                                Код специально тривиален — всё, что я хочу показать — это оверхед по выделению памяти. Мы сто тысяч раз раз выделяем один мегабайт. И вот вам результат (запускал несколько раз подряд, всё «разогрето»):

                                $ time java ThousandGB
                                
                                real    1m1.992s
                                user    0m0.015s
                                sys     0m0.030s
                                


                                $ time ./ThousandGB.exe
                                
                                real    0m1.320s
                                user    0m0.000s
                                sys     0m0.046s
                                


                                Разница порядка 40-50 раз.

                                Вы можете возразить, что я не использую «нативные буферы», но они, по сути, являются той же самой JNI-оберткой вокруг массивов.

                                Вывод №1: как только в Java надо ворочать большими блоками данных, она начинает лагать.
                                Вывод №2: скорость Java-кода варьируется от ~1/2 скорости нативного и до ~1/50, в зависимости от задачи. Если оба коэффициента вам кажутся приемлимой платой за ее удобство и безопасность, используйте Java и радуйтесь. У Python, кажется (когда-то мерял), этот коэффициент начинается от 1/100…
                                  +6
                                  Well, well

                                  А теперь поясните, что я делаю не так:

                                  код на C++:
                                  int main() {
                                  
                                      int* arr;
                                      int sum = 0;
                                      for (int i = 0; i < 10000; i++) {
                                          arr = new int[1024 * 1024];
                                          for(int j = 0; j < 1024 * 1024; j++)
                                                  sum += arr[j];
                                          delete [] arr;
                                      }
                                      return sum;
                                  
                                  }
                                  


                                  и такой же, но на java:
                                  public class A {
                                  public static void main(String... args) {
                                  
                                          final long start = System.nanoTime();
                                  
                                          int sum = 0;
                                          for (int i = 0; i < 10_000; i++) {
                                              int[] arr = new int[1024 * 1024];
                                              for (int j = 0; j < arr.length; j++) {
                                                  sum += arr[j];
                                              }
                                          }
                                  
                                          final long end = System.nanoTime();
                                          System.out.println(sum + " : " + (end - start) / 1e3 / 1000 + " ms");
                                      }
                                  }
                                  


                                  $ g++ a.cpp
                                  $ time ./a.out
                                  
                                  real    0m27.037s
                                  user    0m27.003s
                                  sys     0m0.009s
                                  
                                  $ javac A.java
                                  $ time java A
                                  0 : 8429.422174000001 ms
                                  
                                  real    0m8.522s
                                  user    0m7.944s
                                  sys     0m1.017s
                                  
                                  
                                    +2
                                    Это всё было на 10 тыс циклов, а вот то же самое, но как у вас на 100 тысячах:

                                    $ time java A
                                    0 : 86501.16352799999 ms
                                    
                                    real    1m26.593s
                                    user    1m28.326s
                                    sys     0m1.291s
                                    
                                    $ time ./a.out
                                    
                                    real    4m29.753s
                                    user    4m29.496s
                                    sys     0m0.010s
                                    
                                      0
                                      Спасибо за красивый пример. В очередной раз убедился, что нельзя никогда создавать искусственные бенчмарки, а мерить производительность надо только на реальном коде. Потому что причин, по которым код может работать быстрее/медленнее — миллион. Понимать их можно лучше или хуже, но все подобные примеры — голая софистика.

                                      Мне другое интересно — почему внутренний цикл с арифметикой на Java быстрее? Я ниже подробнее написал — прочтите, пожалуйста: https://habrahabr.ru/post/307268/#comment_9764236
                                    +6
                                    Спасибо за этот пример — добавлю его в копилочку классических ошибок бенчмаркинга :)
                                    Я уже писал, что большинство заблуждений про производительность Java происходят из-за плохого кода и неумения анализировать. Давайте же вместе проанализируем.

                                    Смотрите: ваш тест на C++ выделяет 100 тыс. раз по 4 мегабайта, так? (Размер int обычно 4 байта). Итого около 400 GB. И всё это за одну секунду. При этом пропускная способность самой современной топовой DDR4 памяти составляет всего 25 GB/s. Опа! Ваш тест в 16 раз опередил время!

                                    Подключив здравый смысл, становится очевидно, что ваш тест замеряет что-то не то. (А что именно, кстати?) Более или менее корректный тест уже написал Владимир выше. И из него следуют ровно обратное. Действительно, выделение памяти — это как раз сильная сторона Java. Я на StackOverflow рассказывал, как работает выделение в Java — в большинстве случаев это всего 10-15 тактов CPU. Даже самым крутым сишным аллокаторам (tcmalloc, jemalloc) далеко в этом смысле до JVM.
                                      0
                                      Мн-да. Очень странно было с моей стороны доказывать бессмысленность искуственно созданных бенчмарков с помощью создания СВОЕГО искусственного бенчмарка.

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

                                      По поводу того, что я по ошибке измерил — повидимому, в случае C++ память была выделена один раз, так как компилятор увидел, что она используется в дальнейшем.

                                      Куда больше меня напрягает то, что последующие манипуляции этой памятью в Java быстрее. Этого я реально не ожидал.

                                      То есть, рассмотрим вот такой пример:

                                      int main() {
                                      
                                          int* arr;
                                          int sum = 0;
                                          for (int i = 0; i < 10000; i++) {
                                      		arr = new int[1024 * 1024];
                                              for(int j = 0; j < 1024 * 1024; j++) {
                                      			arr[j] = j * j;
                                      			sum += arr[j];
                                      		}
                                      		delete [] arr;
                                          }
                                      	return sum;
                                      
                                      }
                                      


                                      public class ThousandGB2 {
                                      public static void main(String... args) {
                                              int sum = 0;
                                              for (int i = 0; i < 10000; i++) {
                                      			int[] arr = new int[1024 * 1024];
                                                  for (int j = 0; j < 1024 * 1024; j++) {
                                                      arr[j] = j * j;
                                      				sum += arr[j];
                                                  }
                                              }
                                          }
                                      }
                                      


                                      Результаты:

                                      $ g++ -o ThousandGB2 ThousandGB2.cpp -O3 && time ThousandGB2
                                      
                                      real    0m21.006s
                                      user    0m0.000s
                                      sys     0m0.046s
                                      
                                      $ javac ThousandGB2.java && time java -Xmx300m ThousandGB2
                                      
                                      real    0m15.303s
                                      user    0m0.000s
                                      sys     0m0.047s
                                      


                                      А теперь заменим предел ВНУТРЕННЕГО цикла:

                                      int main() {
                                      
                                          int* arr;
                                          int sum = 0;
                                          for (int i = 0; i < 10000; i++) {
                                      		arr = new int[1024 * 1024];
                                              for(int j = 0; j < 1024 * 100; j++) {
                                      			arr[j] = j * j;
                                      			sum += arr[j];
                                      		}
                                      		delete [] arr;
                                          }
                                      	return sum;
                                      
                                      }
                                      


                                      public class ThousandGB2 {
                                      public static void main(String... args) {
                                              int sum = 0;
                                              for (int i = 0; i < 10000; i++) {
                                      			int[] arr = new int[1024 * 1024];
                                                  for (int j = 0; j < 1024 * 100; j++) {
                                                      arr[j] = j * j;
                                      				sum += arr[j];
                                                  }
                                              }
                                          }
                                      }
                                      


                                      Новые результаты:

                                      $ g++ -o ThousandGB2 ThousandGB2.cpp -O3 && time ThousandGB2
                                      
                                      real    0m2.120s
                                      user    0m0.015s
                                      sys     0m0.015s
                                      
                                      $ javac ThousandGB2.java && time java -Xmx300m ThousandGB2
                                      
                                      real    0m5.848s
                                      user    0m0.000s
                                      sys     0m0.031s
                                      


                                      То есть я наблюдаю следующую картину: количество выделений памяти, предположительно, не изменилось (я прав?). Но внутренний цикл с арифметикой на C++ явно медленнее, чем на Java. То есть компилятор Java знает некоторую хорошую оптимизацию, которую gcc не применяет даже с O3. Я сперва подумал, что речь идет о векторизации цикла и добавил в j-й итерации ссылку на arr[j-1], но резульат оказался примерно тем же.

                                      Вот если бы мне это объяснили, я был бы очень признателен.

                                      А насчет выделения памяти — я абсолютно уверен в том, что сборщик мусора в реальной математической программе создает замедление — я с этим сталкивался непосредственно. И то, что я не смог это проиллюстрировать наглядно, не отменяет этого факта. Я действительно не силен в искусственных бенчмарках.
                                        0
                                        А насчет выделения памяти — я абсолютно уверен в том, что сборщик мусора в реальной математической программе создает замедление — я с этим сталкивался непосредственно. И то, что я не смог это проиллюстрировать наглядно, не отменяет этого факта. Я действительно не силен в искусственных бенчмарках.

                                        Обычно стараются переиспользовать тяжелые объекты (равно как и при использовании того же blas/lapack/arpack в коде на Си или numpy/scipy в случае пайтона). А выделение небольших короткоживущих объектов в рамках TLAB — операция дешевая (выделение реализуется увеличением значения указателя на размер выделяемого объекта + проверка на выход за границу имеющегося блока).


                                        Т. е. если писать на яве "на фортране" и с предвыделенными массивами, то всё очень неплохо. Правда, такой код не сильно отличается от аналогичного на плюсах и обычно пишется на яве ради удобства интеграции с другой частью ява-кода. Другое дело, что ручное использование интринзиков или код, скомпилированный icc с -xHost и т. п., могут дать ещё дополнительный буст по производительности.

                                          0
                                          Правда, такой код не сильно отличается от аналогичного на плюсах и обычно пишется на яве ради удобства интеграции с другой частью ява-кода.

                                          Вот так обычно и писалось. Но главное преимущество для меня — удобство отладки кода. Отладчик Java непревзойден.
                                          +5

                                          Важный момент: Java обязательно обнуляет всю выделенную память. Этого требует модель безопасности. Соответственно она реально пишет в те страницы, которые возвращаются при выделении. Для зануления в C++ используйте new int[1024 * 1024](). Так будет честнее.

                                            0
                                            Да, спасибо! Разумеется, я про это знал, но не подумал. В любом случае, меня удивляет, что Java быстрее, а не то, что она медленнее.
                                            +5
                                            Нет, дело не в оптимизациях. Выделение памяти работает по-другому.

                                            Если попросить достаточно большой кусок памяти (согласно документации, более 128 KB), то new в C++ сводится к системному вызову mmap, который не выделяет память, а лишь резервирует виртуальное адресное пространство. Вся магия происходит при первом обращении к зарезервированной странице: случается page fault, в ответ на который ядро уже коммитит данную страницу, то есть, размещает её в физической памяти.

                                            Стало быть, в тесте на C++ реальное выделение памяти неявно скрыто в обращении к массиву. А если «трогать» лишь первые 100 KB массива, как происходит во втором тесте, то и физически размещено в RAM будет лишь 100 KB. В Java же выделение «честное»: аллоцировали 1 MB — столько же сразу и будет закоммичено. При этом вся область будет ещё обязательно обнулена.
                                              +2

                                              Сделай отдельный доклад про кривые бенчмарки, у тебя материала уже много :-)

                                                0
                                                Интересное кино. Какими кусками выделяется память при обращении к элементам массива? Содается впечатление, что кусочки очень маленькие, потому что выглядит так, как будто при проходе по массиву C++ чуть ли не равномерно идет, со скоростью ниже, чем Java.

                                                Надо бы померить точно для произвольной длины…
                                                  +1
                                                  Страницами, то есть, по 4 КБ. Однако в случае, когда идёт последовательное обращение к виртуальной памяти, ОС может использовать упреждающее чтение, то есть, коммитить последующие страницы заранее.
                                        +3
                                        Эх, вот смотрю на эти вкусности JITа и слюни пускаю, что шарповый умеет разве что вместо интерфейсного метода вызывать его на инстансе, избегая упаковки в случае вызова на структуре…

                                        Only users with full accounts can post comments. Log in, please.