Осторожнее с тем, что измеряете — MJIT vs TruffleRuby: в 2,1 раза медленнее или в 4,2 раза быстрее +26


Вы видели результаты бенчмарков MJIT? Они удивительные, правда? MJIT буквально выносит все остальные реализации без вариантов. Где он был все эти годы? Всё, теперь с гонкой закончено?

Однако вы можете понять из заголовка, что не всё так просто. Но прежде чем разобрать проблемы этих конкретных бенчмарков (конечно, вы можете пролистать вниз к симпатичным диаграммам), нужно рассмотреть важные базовые основы сравнительного анализа.

MJIT? TruffleRuby? Что это всё такое?


MJIT — это ответвление Ruby на Github от Владимира Макарова, разработчика GCC, где реализована динамическая JIT-компиляция (Just In Time Compilation) на самом популярном интерпретаторе Ruby — CRuby. Это отнюдь не окончательная версия, наоборот, проект на ранней стадии разработки. Многообещающие результаты бенчмарков были опубликованы 15 июня 2017 года, и это основной предмет обсуждения в данной статье.

TruffleRuby — это реализация Ruby на GraalVM от Oracle Labs. Она демонстрирует впечатляющие результаты производительности, как вы могли видеть в моей прошлой статье “Ruby plays Go Rumble”. В ней тоже реализован JIT, ей нужен небольшой разогрев, но в итоге она примерно в 8 раз быстрее, чем Ruby 2.0 в вышеупомянутом бенчмарке.

Прежде чем продолжить...


Я невероятно уважаю Владимира и думаю, что MJIT — исключителньо ценный проект. Реально это может быть одна из немногих попыток ввести JIT в стандартный Ruby. В JRuby много лет имеется JIT и она демонстрирует хорошую производительность, но эта реализация так и не стала по-настоящему популярной (это тема для другой статьи).

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

И ещё раз повторю, здесь дело не в личности разработчика, а именно в способе проведения бенчмарков. Владимир, если ты это читаешь,

Что мы измеряем?


Когда видишь результаты бенчмарков, то первым делом возникает вопрос: «Что измерялось?» Здесь ответ двоякий: код и время.

Какой код мы измеряем?


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

Если посмотреть на список бенчмарков в файле README (и пролистать до описания, что они значат или изучить их код), то вы увидите, что практически вся верхняя половина — это микро-тесты:



Здесь измеряется запись в переменные инстанса, чтение констант, вызовы пустого метода, циклы while и тому подобное. Это исключительно миниатюрные тесты, может быть, интересные с точки зрения реализаторов языка, но не очень отображающие реальную производительность Ruby. Тот день, когда поиск константы станет бутылочным горлышком в производительности вашего приложения Ruby, будет самым счастливым днём. И какая часть вашего кода содержит циклы while?

Большая часть кода здесь (не считая микропримеров) — не совсем то, что я бы назвал типичным кодом Ruby. Многое больше похоже на смесь скриптов и C-кода. Много где не определены классы, используются циклы while и for вместо более обычных методов Enumerable, а кое-где есть даже битовые маски.

Некоторые из этих конструкций могли возникнуть в результате оптимизации. По-видимому, они используются в общих языковых тестах. Это тоже опасно, хотя большинство из них оптимизировано для одной конкретной платформы, в данном случае CRuby. Самый быстрый код Ruby на одной платформе может быть гораздо медленнее на других платформах из-за деталей реализации (например, в TruffleRuby используется иная реализация String). Естественно, из-за этого другие реализации находятся в невыгодном положении.

Здесь проблема немного глубже: что бы не содержалось в популярном бенчмарке, оно неизбежно будет лучше оптимизировано для какой-то реализации, но этот код должен быть максимально приближен к реальному. Поэтому меня очень радуют бенчмарки проекта Ruby 3?3, эти новые тесты показывают как будто более релевантный результат.

Какое время мы замеряем?


Это на самом деле моя любимая часть статьи и бесспорно самая важная. Насколько я знаю, замеры времени в оригинальной статье производились следующим образом: /usr/bin/time -v ruby $script, а это одна из моих любимых ошибок в бенчмарках для языков программирования, которые широко используются в веб-приложениях. Вы можете подробнее послушать об этом в моём выступлении на конференции здесь.

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



  • Запуск — время до того, как мы сделаем что-нибудь «полезное» типа запуска Ruby Interpreter и парсинга кода. Для справки, исполнение пустого файла Ruby средствами стандартного Ruby у меня занимает 0,02 секунды, в MJIT — 0,17 с, а в TruffleRuby — 2,5 с (хотя есть планы значительно сократить это время с помощью Substrate VM). Эти секунды по сути добавляются к каждому бенчмарку, если вы измеряете просто время выполнения скрипта.
  • Разогрев — время до того, как программа начнёт работать на полной скорости. Это особенно важно для реализаций на JIT. На высоком уровне происходит следующее: они видят, какой код вызывается чаще всего, и пытаются дальше его оптимизировать. Этот процесс занимает много времени, и только по его окончании мы можем говорить о достижении настоящей «пиковой производительности». На разогреве программа может работать гораздо медленнее, чем на пике производительности. Ниже мы более подробно проанализируем тайминги разогрева.
  • Исполнение — то, что мы называем «пиковой производительностью» — с устоявшимся временем выполнения. К этому этапу бoльшая часть или весь код уже оптимизирован. Это уровень производительности, на котором код будет выполняться с этого момента и в будущем. В идеале, нужно измерять именно эту производительность, потому что более 99,99% времени наш код будет выполняться в уже разогретом состоянии.

Интересно, что в оригинальном бенчмарке подтверждается наличие времени запуска и разогрева, но эти показатели обрабатываются таким образом, что их эффект смягчается, но не полностью исчезает: «MJIT очень быстро запускается, в отличие от JRuby и Graal Ruby. Чтобы подравнять шансы для JRuby и Graal Ruby, бенчмарки модифицированы таким образом, чтобы Ruby MRI v2.0 работал 20?70 с на каждом тесте».

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

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

В качестве небольшой аналогии можно привести автомобиль. Вы собираетесь проехать 300 километров за минимальное время (по прямой). Измерение как в таблице выше можно сравнить с измерением примерно первых 500 метров. Сесть в машину, разогнаться до максимальной скорости и может быть немного проехать на пике. Действительно ли самая быстрая машина на первых 500 метрах проедет 300 километров быстрее всех? Вероятно, нет. (Примечание: я плохо разбираюсь в автомобилях).

Что это значит для нашего бенчмарка? В идеале нам нужно убрать из него время загрузки и разогрева. Это можно сделать, используя библиотеку тестирования, написанную на Ruby, которая сначала запускает бенчмарк несколько раз, прежде чем производить реальные замеры (время разогрева). Мы используем мою собственную маленькую библиотеку, поскольку ей не требуется gem и она хорошо приспособлена для долговременного тестирования.

Действительно ли время загрузки и разогрева никогда не имеют значения? Имеют. Наиболее заметно они влияют в процессе разработки — запустить сервер, перезагрузить код, провести тесты. За все эти задачи вам нужно «платить» временем загрузки и разогрева. Также, если вы разрабатываете приложение UI или инструмент CLI для конечных пользователей, время загрузки и разогрева могут стать проблемой, поскольку загрузка происходит гораздо чаще. Вы не можете просто разогреть его перед отправкой в балансировщик нагрузки. Ещё периодический запуск процессов как cronjob на сервере тоже вынуждает тратить время на загрузку и разогрев.

Так есть ли преимущества в том, чтобы измерять время загрузки и разогрева? Да, это важно для одной из вышеперечисленных ситуаций. И измерение с параметром time -v выдаёт гораздо больше данных:

tobi@speedy $ /usr/bin/time -v ~/dev/graalvm-0.25/bin/ruby pent.rb
Command being timed: "/home/tobi/dev/graalvm-0.25/bin/ruby pent.rb"
User time (seconds): 83.07
System time (seconds): 0.99
Percent of CPU this job got: 555%
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:15.12
Average shared text size (kbytes): 0
Average unshared data size (kbytes): 0
Average stack size (kbytes): 0
Average total size (kbytes): 0
Maximum resident set size (kbytes): 1311768
Average resident set size (kbytes): 0
Major (requiring I/O) page faults: 57
Minor (reclaiming a frame) page faults: 72682
Voluntary context switches: 16718
Involuntary context switches: 13697
Swaps: 0
File system inputs: 25520
File system outputs: 312
Socket messages sent: 0
Socket messages received: 0
Signals delivered: 0
Page size (bytes): 4096
Exit status: 0


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

Конфигурация


Перед тем, как мы (наконец-то!) перейдём к бенчмаркам, нужна обязательная часть «Вот система, на которой проводились измерения».

Использовались следующие версии Ruby: MJIT из этого коммита за 25 августа 2017 года, скомпилированный без особых настроек, graalvm 25 и 27 (подробнее об этом чуть позже) и CRuby 2.0.0-p648 как базовый уровень.

Всё это запускалось на моём настольном ПК под Linux Mint 18.2 (на базе Ubuntu 16.04 LTS) с 16 ГБ памяти и процессором i7-4790 (3,6 ГГц, 4  ГГц турбо).

tobi@speedy ~ $ uname -a
Linux speedy 4.10.0-33-generic #37~16.04.1-Ubuntu SMP Fri Aug 11 14:07:24 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux


Мне кажется особенно важным упомянуть здесь конфигурацию, потому что когда я сначала делал тесты для конференции Polyconf на своём двухъядерном ноутбуке, результаты TruffleRuby оказались гораздо хуже. Думаю, что graalvm выигрывает от двух дополнительных ядер на разогреве и т.д., поскольку использование CPU по ядрам довольно высокое.

Можете проверить скрипт для тестирования и всё остальное в этом репозитории.

Но… ты обещал бенчмарки, где они?


Простите, мне показалось, что теория более важна, чем сами результаты тестов, хотя они, без сомнения, помогают проиллюстрировать тезис. Сначала поясню, почему я выбрал бенчмарк pent.rb и почему я запускал его на немного устаревших версиях graalvm (не беспокойтесь, текущая версия тоже в деле). И тогда, наконец, появятся графики и цифры.

Почему этот бенчмарк?


Прежде всего, оригинальные тесты производительности запускались на graalvm-0.22. Попытка воспроизвести результаты с текущей (на данный момент) версией graalvm-0.25 оказалась сложной, поскольку многие из них были уже оптимизированы (а версия 0.22 содержит несколько аутентичных багов производительности).

Единственным бенчмарком, где я смог воспроизвести проблемы производительности, оказался pent.rb, и он также наиболее явно показывал аномалию. В оригинальных бенчмарках он отмечен как 0,33 производительности Ruby 2.0 (то есть в три раза медленнее). Но весь мой опыт работы с TruffleRuby говорил, что это наверняка неправильно. Так что я исключил его не потому что он был самым быстрым на TruffleRuby, а наоборот — он был самым медленным.

Более того, хотя это во многом не самый характерный код Ruby, на мой взгляд (нет классов, много глобальных переменных), там используется много методов Enumerable, таких как each, collect, sort и uniq, в то же время отсутствуют битовые маски и тому подобное. Так что мне казалось, что здесь я тоже получу сравнительно хорошего кандидата.

Оригинальный бенчмарк был помещён в цикл и повторялся несколько раз, так что можно измерить время разогрева, а затем среднее время работы на пике производительности.

Так почему я запустил его на старой версии graalvm-0.25? Ну, что бы там ни оптимизировали для бенчмарка, разница здесь будет менее очевидной.

Бенуа Далоуз в твите ниже пишет, что оптимизировал время разогрева TruffleRuby для этого бенчмарка, так что теперь TruffleRuby в три раза быстрее MJIT. Он обращает внимание, что в коде бенчмарка pen.rb для передачи значения методу используется глобальная переменная вместо аргумента.


Позже мы испытаем новую улучшенную версию.

MJIT vs Graalvm-0.25


Итак, на моей машине первоначальное выполнение бенчмарка pent.rb (время загрузки, разогрева и выполнения) на TruffleRuby 0.25 заняло 15,05 секунды и всего лишь 7,26 с на MJIT. То есть MJIT оказался в 2,1 раза быстрее. Впечатляет!

Но что есть не учитывать загрузку и разогрев? Что если начать измерения уже после запуска интерпретатора? В данном случае мы запускаем код в цикле на 60 секунд для разогрева, а затем 60 секунд измеряем реальную производительность. На диаграмме показано время выполнения теста для первых 15 итераций (после этого TruffleRuby стабилизируется):


Время выполнения в TruffleRuby и MJIT постепенно изменяется — итерация за итерацией

Как видите, TruffleRuby начинает гораздо медленнее, но затем быстро набирает скорость, в то время как MJIT продолжает работать более-менее одинаково. Интересно, что TrufleRuby опять замедляется в итерациях 6 и 7. Или он нашёл новую оптимизацию, которой понадобилось значительное время для завершения, или произошла деоптимизация из-за того, что ограничения предыдущей оптимизации оказались более не действительны. После этого TruffleRuby стабилизируется и выходит на пиковую производительность.

При запуске бенчмарка после разогрева мы получаем среднее время у TruffleRuby 1,75 с, а у MJIT — 7,33 с. То есть при таком способе измерения TruffleRuby неожиданно в 4,2 раза быстрее, чем MJIT.

Вместо результата в 2,1 раза медленнее мы получили результат в 4,2 раза быстрее, просто изменив метод измерения.

Мне нравится представлять результаты теста в виде количества итераций в секунду/минуту (ips/ipm), поскольку здесь чем больше — тем лучше, так что графики выходят более интуитивно понятными. Наше время выполнения преобразуется в 34,25 итерации в минуту у TruffleRuby и в 8,18 итераций в минуту у MJIT. Так что посмотрим теперь на результаты теста, преобразованные в виде итераций в минуту. Здесь сравнивается первоначальный метод измерения и наш новый метод:


Количество итераций в минуту при выполнении всего скрипта (начальное время) и количество итераций после разогрева

Большая разница в результатах TruffleRuby вызвана длительным временем разогрева во время нескольких первых итераций. С другой стороны, MJIT очень стабилен. Разница вполне в пределах статистической погрешности.

Ruby 2.0 vs MJIT vs Graalvm-0.25 vs GRAALVM-0.27


Итак, я обещал вам больше данных, и вот они! Этот набор данных также включает в себя показатели CRuby 2.0 как базового уровня для сравнения, а также нового graalvm.

начальное время (сек) ipm начального времени среднее (сек) ipm среднего после разогрева стандартное отклонение как часть общего
CRuby 2.0 12.3 4.87 12.34 4.86 0.43%
TruffleRuby 0.25 15.05 3.98 1.75 34.25 0.21%
TruffleRuby 0.27 8.81 6.81 1.22 49.36 0.44%
MJIT 7.26 8.26 7.33 8.18 2.39%


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

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


Среднее количество итераций в минуту после разогрева для четырёх участников тестирования

Так что в TruffleRuby 0.27 не только ускорился разогрев, но в целом немного повысилась производительность. Теперь он более чем в 6 раз быстрее MJIT. Конечно, частично это произошло потому, что разработчики TruffleRuby, вероятно, осуществили оптимизацию для существующего бенчмарка. Это ещё раз подчёркивает мой тезис о том, что нам нужны более качественные тесты производительности.

В качестве последней причудливой диаграммы у меня есть сравнение общего времени выполнения скрипта (через time) и производительности после разогрева, по количеству итераций в минуту.


Разница между общим временем выполнения скрипта (итераций в минуту) и производительностью после разогрева

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

Заключение


Итак, какой можно сделать вывод? Время загрузки и разогрева имеет значение, и вам следует хорошенько подумать, насколько данные показатели важны для вас и нужно ли их замерять. Для веб-приложений время загрузки и разогрева практически не имеет значения, потому что более 99,99% времени программа демонстрирует производительность уже после разогрева.

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

ВСЕГДА ЗАПУСКАЙТЕ СОБСТВЕННЫЕ БЕНЧМАРКИ И СМОТРИТЕ, КАКОЙ КОД ЗАМЕРЯЕТСЯ, КАК ЭТО ДЕЛАЕТСЯ И КАКОЕ ВРЕМЯ УЧИТЫВАЕТСЯ В БЕНЧМАРКЕ




К сожалению, не доступен сервер mySQL