Comments 48
Вот знаменитый вопрос на StackOverflow. Или ещё полезный пост с примерами.
По моим наблюдениям самые частые ошибки такие.
- Один большой метод, в котором замеряют все алгоритмы. Это в корне неверно. Единица JIT компиляции — метод. Чем длиннее метод, тем сложнее его оптимизировать. Скорее всего, код будет адаптирован лишь под один из алгоритмов, по которому успела собраться run-time статистика. Разные алгоритмы надо запускать в отдельных JVM.
- Весь бенчмарк — один длинный цикл в main(). Таким образом, вместо полноценно скомпилированного метода тестируют OSR заглушку. Один прогон бенчмарка следует поместить в отдельный метод, который запускается несколько раз.
- Замеры надо проводить в устойчивом состоянии, когда все классы уже загружены, все компиляции-рекомпиляции уже прошли и т. д. Нет единого «золотого» числа итераций, после которого приложение заведомо будет работать с максимальной скоростью. Для одних тестов это пара секунд, для других — несколько минут.
- «Прогревать» следует ровно тот код, который измеряется. Иначе Profile Pollution может всё испортить.
- Бенчмарк должен иметь эффект, чтобы JIT не выкинул код, который что-то вычисляет, но нигде результаты вычислений не использует.
- Самые честные замеры — на свежей 64-битной JDK в режиме Tiered или C2. Показатели 32-битной JVM и 64-битной, «клиентского» компилятора и «серверного» могут отличаться на порядок.
- Убедитесь, что памяти достаточно, и что GC не оказывает влияния на производительность.
- Минимизируйте сторонние эффекты. Избегайте лишних
println
-ов,nanoTime
-ов и прочих операций, не имеющих отношения к измеряемому коду.
Если разница будет существенной (скажем, на порядок) — то скорее всего и на пользовательской машине все будет примерно также.
Ждал про то как меняется производительность от контекста, и что с этим делать.
Перед глазами сразу встала картина конца девяностых.
Мой командир (я учился в полувоенном вузе) попросил меня сделать ему программу для автоматического составления графика нарядов. Но непременно в Экселе.
На тот момент я ничего лучше не придумал, чем сделать это всё на рекурсивных ссылках. Работало медленно, но в целом скорость была приемлима.
Через месяц я таки узнал что есть такое понятие как VBA и решил переписать всё по человечески, процедурой.
Полдня работы, и новая версия готова.
отработала она у меня на порядок, если не на два быстрее.
Приношу к командиру, а она у него работает медленнее чем прошлая версия.
Не помню в чем конкретно был момент, давно это было, да и говнокода своего уже не вспомнить. Но помню что разгадка была в том, что у одного из компов был полноценный сопроцессор, вроде даже ММХ, а второй был стареньким клоном 486, но зато памяти было аж 32мб, в отличии от 16мб на другом.
Для меня это тогда было настоящим откровением. Я примерно догадывался что профиль железа может несколько исказить пропорции в производительности, но чтобы вот так, прямо противоположным образом, то не думал…
Сначала мы пишем на Ассемблере, и говорим что сложно писать большие сложные программы, потом придумываем С.
Потом мы пишем на СИ, а потом говорим что тяжело без ООП, Придумываем С++.
Потом мы пишем на С++ и понимаем что указатели зло. Потом придумываем Java и С#, потом говорим что они очень медленные ))
Каждый язык что-то чинит )) А перфоманс лишь последствие того что мы решили починить в других языках ))
Недавно посмотрел https://youtu.be/_79KfX-3sQc?t=70 очень позновательно
Загрузить классы самого Stream API (их там немало)
что-то сомневаюсь, что он прям весь Stream API загружает.
Но согласен, что загрузка классов в Java может занимать существенное время.
Интересный вопрос и это легко проверить. Однократный вызов метода 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 прогон, чтобы точно загрузились классы — с этим никто не спорит.
Но вот давать jit компилятору набрать статистику и оптимизировать код — это уже большой вопрос.
Во-первых, тестовые данные это обычно примитивная лажа с потолка. Ну, условно, в примере из статьи берутся числа от 1 до 100000, причём подряд, но в реальных задачах будут те же 100000 чисел, но каких-нибудь реальных. И статистика и, соответственно, оптимизации будут совсем другими.
Второе — в программах на Java довольно редко один и тот же алгоритм гоняется в цикле тысячи раз. Такие алгоритмы это обычно какой-нибудь матан, и его чаще всего не на Java считают.
Соответственно в реальной программе тестируемый метод будет вызываться, вероятно, относительно редко, единицы-десятки раз за прогон. Т.е. на практике не будет статистики, не будет мегамахровой оптимизации, и как раз важно время второго-третьего прогона, а не миллионного.
в реальной программе тестируемый метод будет вызываться, вероятно, относительно редкоРедко вызываемый код почти никогда не будет проблемой. Ну, отработает он за 10мс. Ну, за 100… Человек разницы даже не заметит. А если код работает долго — значит, там, есть циклы, есть повторения… Те самые тысячи и миллионы прогонов по одним и тем же инструкциям, которые JIT и призван оптимизировать.
Редко вызываемый код почти никогда не будет проблемой
Ну конечно.
Java — это, обычно, махровый энтерпрайз.
То есть имеются тысячи планктона, которые чего-то там в систему вводят.
И есть биг-босс, который раз в неделю или раз в квартал смотрит отчёт.
И вот ему этот самый отчёт нужно предоставить очень быстро. Иначе он сочтёт, что ваша программа «тормозит» и вообще «не очень».
Трюк в том, что именно этот биг-босс принимает решение о финансировании. А не рядовые клерки.
Ну и? Ваши действия?
У вас в голове неправильный масштаб времени происходящего. Смотрите: если метод выполняется один раз и не имеет блокировок (будь то mutex или i/o, или другой синхронный системный вызов), он либо выполняется не больше миллисекунды (даже в интерпретируемом режиме), либо вызывает другие методы в цикле, либо содержит большой цикл, который не вызывает других методов. В первом случае даже бигбоссу плевать. Во втором уже через несколько итераций цикла все вызываемые методы будут JIT-компилированы и код уже разгонится до весьма приличной скорости (а потом дооптимизируется до максимальной). В третьем (который очень редкий в реальной жизни) спасает механизм OSR. В любом случае меньше чем за секунду даже гигантский объём кода будет весь приведён в боевое состояние, поэтому бигбоссу плевать на JIT. Тормоза в Java кроются совсем в других местах (как раз i/o, избыточная синхронизация, сборка мусора, если программа чрезмерно гадит, и т. д.)
потом можно продолжить поисковым движком elasticsearch (напомните мне схожий по возможностям продукт не на java, вроде sphinx что-то умеет, правда количество упоминаний и инсталяций в разы меньше)
дальше продолжаем быстрой распределенной очередью, чтобы заменить kafka (zeromq это конструктор, а не готовая распределенная персистентная очередь)
так как нам нужно распределенно считать, то следом в очередь spark, storm (даже у последователя heronосновной объем кода это java), gearpump
можно еще упомянуть hbase (бедные Airbnb и Xiaomi не знаю, что нельзя его было использовать) или его сородича accumulo (писалась по заказу АНБ с требованием жестких ACL вплоть до отдельной ячейки)
вот так сидят все и формочки клепают
Ещё в копилку классных проектов на Java — Apache Cassandra
1. Возьмите самое сердце вашего кода — самый важный и тормозной кусок.
2. Перепишите его на другом языке. Для начала, буквально перепишите. Без оптимизаций.
3. Померяйте производительность.
Если в переписанном куске уже стало намного быстрее, значит однозначно меняйте язык для этого куска. Если нет, то смотрите внимательно, что можно оптимизировать. Например, если переписывать с Java на C, то можно применить векторизацию… В общем, думайте.
Но Python почти всегда намного медленнее, чем Java. И это — факт. Трудно придумать тест, на котором будет наоборот.
Искусственные бенчмарки почти никогда не дают верного результата, потому что меряют не то, что нужно конкретно вам. Единственный правильный бенчмарк — это профилирование вашей собственной программы. Написали — тормозит — запустили профилировщик — нашли узкие места — поправили. Обычно «поправили» — и есть смена языка. Например, с Java на C+JNI.
Просто, иногда (в не слишком заковыристых случаях) для профилирования дополнительных интрументов не нужно — пары ловких Unit-тестов достаточно, чтобы оценить производительность.
Смена языка на основе левых бенчмарков — вот это реально глупость.
Вообще-то «запустили профилировщик» — это тоже не так просто. В зависимости от ситуации профилировщик легко может выдать мусор вместо реальных данных. Программа под профилировщиком ведёт себя не так как программа в продакшне.
Обычно «поправили» — и есть смена языка.
Мой опыт говорит, что большинство проблем с производительностью на Java решается оптимизацией Java-кода, а не впиливанием C+JNI. Очень редко впиливать C+JNI оправданно. У вас другой опыт? Можете рассказать пару историй успеха, когда вам пришось впилить C+JNI?
Java эффективна в управлении, но не годна для математики из-за своей «тяжелой» концепции управления памятью. Но так как отлаживать код на ней легко и приятно, часто удобно сперва написать весь алгоритм на Java, затем полюбоваться, как тормозит «внутренний цикл», запустить VisualVM, обнаружить самые «узкие» места и переписать их на C++.
Чувак пишет бенчмарк, замеряющий скорость работы с memory-mapped файлом на Java и на C.
Запускает… Программа на Java работает в 500 раз медленнее! Какой вывод? Дрянь эта ваша Java!
Но надо отдать должное автору, он не стал сразу ругать Java, а обратился на StackOverflow с вопросом «почему?» Можно было пойти дальше и проанализировать самому, хотя бы с помощью профайлера. И тут становится понятно, что Java-то не виновата. Это программист дёргает на каждый байтик file.size(). А если переписать тест грамотно, то окажется, что разница в скорости вообще копеечная.
При неиспользовании выделения/освобождения памяти ее производительность сопоставима с оной у 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…
А теперь поясните, что я делаю не так:
код на 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
$ 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
Мне другое интересно — почему внутренний цикл с арифметикой на Java быстрее? Я ниже подробнее написал — прочтите, пожалуйста: https://habrahabr.ru/post/307268/#comment_9764236
Я уже писал, что большинство заблуждений про производительность Java происходят из-за плохого кода и неумения анализировать. Давайте же вместе проанализируем.
Смотрите: ваш тест на C++ выделяет 100 тыс. раз по 4 мегабайта, так? (Размер int обычно 4 байта). Итого около 400 GB. И всё это за одну секунду. При этом пропускная способность самой современной топовой DDR4 памяти составляет всего 25 GB/s. Опа! Ваш тест в 16 раз опередил время!
Подключив здравый смысл, становится очевидно, что ваш тест замеряет что-то не то. (А что именно, кстати?) Более или менее корректный тест уже написал Владимир выше. И из него следуют ровно обратное. Действительно, выделение памяти — это как раз сильная сторона Java. Я на StackOverflow рассказывал, как работает выделение в Java — в большинстве случаев это всего 10-15 тактов CPU. Даже самым крутым сишным аллокаторам (tcmalloc, jemalloc) далеко в этом смысле до JVM.
Спасибо за интересный пример товарищу сверху. Он в очередной раз меня убедил в том, что единственный способ понять слабые и сильные стороны производительности — это использовать профилировщик. Все остальные методы — полезное упражнение на знание внутреннего устройства системы, но не более того.
По поводу того, что я по ошибке измерил — повидимому, в случае 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], но резульат оказался примерно тем же.
Вот если бы мне это объяснили, я был бы очень признателен.
А насчет выделения памяти — я абсолютно уверен в том, что сборщик мусора в реальной математической программе создает замедление — я с этим сталкивался непосредственно. И то, что я не смог это проиллюстрировать наглядно, не отменяет этого факта. Я действительно не силен в искусственных бенчмарках.
А насчет выделения памяти — я абсолютно уверен в том, что сборщик мусора в реальной математической программе создает замедление — я с этим сталкивался непосредственно. И то, что я не смог это проиллюстрировать наглядно, не отменяет этого факта. Я действительно не силен в искусственных бенчмарках.
Обычно стараются переиспользовать тяжелые объекты (равно как и при использовании того же blas/lapack/arpack в коде на Си или numpy/scipy в случае пайтона). А выделение небольших короткоживущих объектов в рамках TLAB — операция дешевая (выделение реализуется увеличением значения указателя на размер выделяемого объекта + проверка на выход за границу имеющегося блока).
Т. е. если писать на яве "на фортране" и с предвыделенными массивами, то всё очень неплохо. Правда, такой код не сильно отличается от аналогичного на плюсах и обычно пишется на яве ради удобства интеграции с другой частью ява-кода. Другое дело, что ручное использование интринзиков или код, скомпилированный icc
с -xHost
и т. п., могут дать ещё дополнительный буст по производительности.
Важный момент: Java обязательно обнуляет всю выделенную память. Этого требует модель безопасности. Соответственно она реально пишет в те страницы, которые возвращаются при выделении. Для зануления в C++ используйте new int[1024 * 1024]()
. Так будет честнее.
Если попросить достаточно большой кусок памяти (согласно документации, более 128 KB), то
new
в C++ сводится к системному вызову mmap
, который не выделяет память, а лишь резервирует виртуальное адресное пространство. Вся магия происходит при первом обращении к зарезервированной странице: случается page fault, в ответ на который ядро уже коммитит данную страницу, то есть, размещает её в физической памяти.Стало быть, в тесте на C++ реальное выделение памяти неявно скрыто в обращении к массиву. А если «трогать» лишь первые 100 KB массива, как происходит во втором тесте, то и физически размещено в RAM будет лишь 100 KB. В Java же выделение «честное»: аллоцировали 1 MB — столько же сразу и будет закоммичено. При этом вся область будет ещё обязательно обнулена.
Сделай отдельный доклад про кривые бенчмарки, у тебя материала уже много :-)
Надо бы померить точно для произвольной длины…
Супермедленный и супербыстрый бенчмарк