Статьи

Производительность потока

Когда я читаю учебник по производительности Java Анджелики Лангер — Насколько быстры потоки Java 8? Я не мог поверить, что для конкретной операции они занимали примерно в 15 раз больше времени, чем для циклов. Может ли потоковая производительность быть настолько плохой? Я должен был узнать!

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

обзор

Как обычно, я начну с унылого пролога. Это объяснит, почему вы должны быть очень осторожны с тем, что я представляю здесь, как я создал цифры, и как вы можете легко повторить и настроить тест. Если вас это не волнует, перейдите прямо к Stream Performance .

Но сначала два быстрых указателя: на GitHub запущен весь тестовый код, и эта электронная таблица Google содержит полученные данные.

пролог

отказ

Этот пост содержит много цифр, а цифры обманчивы. Они кажутся научными, точными и прочим, и они заманивают нас сосредоточиться на их взаимосвязи и интерпретации. Но мы всегда должны уделять одинаковое внимание тому, как они появились!

Числа, которые я приведу ниже, были получены в моей системе с очень конкретными тестами. Их легко обобщить! Я также должен добавить, что у меня есть только двухдневный опыт работы с нетривиальными методиками бенчмаркинга (то есть тех, которые не основаны на цикле и ручном System.currentTimeMillis() ).

Будьте очень осторожны с включением полученных вами идей в вашу модель умственной деятельности. Дьявол, скрывающийся в деталях, — это сама JVM, и это лживый зверь. Вполне возможно, что мои тесты стали жертвами оптимизаций, которые исказили цифры.

система

  • Процессор : процессор Intel® Core ™ TM i7-4800MQ с тактовой частотой 2,70 ГГц
  • Оперативная память : Samsung DDR3 16GB @ 1.60GHz (тестирование проводилось исключительно в оперативной памяти)
  • ОС : Ubuntu 15.04. Версия ядра 3.19.0-26-generic
  • Java : 1.8.0_60
  • JMH : 1.10.5

эталонный тест

JMH

Тесты были созданы с использованием замечательного Java Microbenchmarking Harness (JMH) , который разработан и используется самой командой производительности JVM. Он тщательно документирован, прост в настройке и использовании, а объяснение с помощью примеров — потрясающее!

Если вы предпочитаете случайное вступление, вам может понравиться выступление Алексея Шипилева на Devoxx UK 2013 .

Настроить

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

Я провел отдельные тесты с 50 000, 500 000, 5 000 000, 10 000 000 и 50 000 000 элементов. За исключением последнего, у всех было две вилки, каждая из которых состояла из пяти прогревов и пяти итераций измерения, где каждая итерация длилась три секунды. Части последней выполнялись в одну разветвительную линию, две прогрева и три итерации измерения, каждая продолжительностью 30 секунд.

В статье Лангера говорится, что их массивы заполнены случайными целыми числами. Я сравнил это с более приятным случаем, когда каждое int в массиве равно его позиции в нем. Отклонение между двумя сценариями в среднем составило 1,2%, при этом наибольшая разница составила 5,4%.

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

Код

Сам код теста доступен на GitHub . Чтобы запустить его, просто перейдите в командную строку, соберите проект и выполните полученный jar:

Построить и запустить тесты

1
2
mvn clean install
java -jar target/benchmarks.jar

Несколько простых настроек:

  • добавление регулярного выражения в конце вызова выполнения будет только тестировать методы, полное имя которых соответствует этому выражению; например, чтобы только запустить ControlStructuresBenchmark :
    1
    java -jar target/benchmarks.jar Control
  • аннотации на AbstractIterationBenchmark определяют, как часто и как долго выполняется каждый тест
  • константа NUMBER_OF_ELEMENTS определяет длину массива / списка, который перебирается
  • настроить CREATE_ELEMENTS_RANDOMLY для переключения между массивом упорядоченных или случайных чисел

Производительность потока

Повторяя эксперимент

Давайте начнем со случая, который побудил меня написать этот пост: поиск максимального значения в массиве из 500 000 случайных элементов.

SimpleOperationsBenchmark.array_max_for

1
2
3
4
int m = Integer.MIN_VALUE;
for (int i = 0; i < intArray.length; i++)
    if (intArray[i] > m)
        m = intArray[i];

Первое, что я заметил: мой ноутбук работает намного лучше, чем машина, использованная для статьи JAX. Этого следовало ожидать, так как его называли «устаревшее оборудование (двухъядерное, без динамического разгона)», но тем не менее меня порадовало, поскольку я заплатил достаточно за эту чертову штуку. Вместо 0,36 мс потребовалось всего 0,130 мс, чтобы пройти через массив. Более интересны результаты использования потока, чтобы найти максимум:

SimpleOperationsBenchmark.array_max_stream

1
2
// article uses 'reduce' to which 'max' delegates
Arrays.stream(intArray).max();

Для этого Лангер сообщает, что время выполнения составляет 5,35 мс, что по сравнению с 0,36 мс цикла дает сообщенное замедление на х15. Я постоянно измерял около 560 мс, поэтому в итоге я получил «только» х4,5. Тем не менее, еще много.

Далее в статье сравниваются итерации по спискам с их потоковой передачей.

SimpleOperationsBenchmark.list_max_for

1
2
3
4
5
6
7
// for better comparability with looping over the array
// I do not use a "for each" loop (unlike the Langer's article);
// measurements show that this makes things a little faster
int m = Integer.MIN_VALUE;
for (int i = 0; i < intList.size(); i++)
    if (intList.get(i) > m)
        m = intList.get(i);

SimpleOperationsBenchmark.list_max_stream

1
intList.stream().max(Math::max);

Результаты составляют 6,55 мс для цикла for и 8,33 мс для потока. Мои измерения составляют 0,700 мс и 3,272 мс. Хотя это значительно меняет их относительную производительность, он создает тот же порядок:

Анжелика Лангер мне
операция время (мс) помедленнее время (мс) помедленнее
array_max_for 0,36 0,123
array_max_stream 5,35 14’861% 0,599 487%
list_max_for 6,55 22% 0,700 17%
list_max_stream 8,33 27% 3,272 467%

Я приписываю заметную разницу между итерациями над массивами и списками; точнее к получающейся косвенности. Примитивный массив упакован со значениями, которые нам нужны, но список поддерживается массивом Integers , то есть ссылками на желаемые значения, которые мы должны сначала разрешить.

Значительная разница между серией относительных изменений Лангера и моей (+ 14’861% + 22% + 27% против + 487% + 17% + 467%) подчеркивает ее утверждение о том, что «модель производительности потоков не является тривиальной ».

Завершая эту часть, ее статья делает следующее наблюдение:

Мы просто сравниваем два целых числа, которые после JIT-компиляции представляют собой всего несколько инструкций по сборке. По этой причине наши тесты иллюстрируют стоимость доступа к элементам, которая не обязательно должна быть типичной ситуацией. Показатели производительности существенно изменяются, если функциональность, применяемая к каждому элементу в последовательности, является интенсивной. Вы обнаружите, что больше нет ощутимой разницы между циклом for и последовательным потоком, если функциональность тесно связана с процессором.

Итак, давайте заблокируем что-то еще, а не просто целочисленное сравнение.

Сравнение операций

Я сравнил следующие операции:

  • max: поиск максимального значения.
  • сумма: вычисление суммы всех значений; агрегируется в int игнорируя переполнения.
  • арифметика: чтобы смоделировать менее простую числовую операцию, я объединил значения с горсткой битовых сдвигов и умножений.
  • строка: чтобы смоделировать сложную операцию, которая создает новые объекты, я преобразовал элементы в строки и xor’ed их символ за символом.

Это были результаты (для 500’000 упорядоченных элементов; в миллисекундах):

Максимум сумма арифметика строка
массив список массив список массив список массив список
за 0,123 0,700 0,186 0,714 4,405 4,099 49,533 49,943
ручей 0,559 3,272 1,394 3,584 4,100 7,776 52,236 64,989

Это подчеркивает, насколько дешевое сравнение на самом деле, даже сложение занимает колоссальные 50% больше. Мы также можем увидеть, как более сложные операции сближают циклы и потоки. Разница падает с почти 400% до 25%. Аналогично, разница между массивами и списками значительно уменьшается. По-видимому, арифметические и строковые операции связаны с процессором, поэтому разрешение ссылок не оказало негативного влияния.

(Не спрашивайте меня, почему для арифметической операции потоковая передача элементов массива происходит быстрее, чем их зацикливание. Некоторое время я бился головой об стену.)

Итак, давайте исправим операцию и рассмотрим механизм итерации.

Сравнение итерационных механизмов

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

Итерация была реализована с прямыми циклами for и for-each. Для потоков я сделал несколько дополнительных экспериментов:

В штучной упаковке и без коробки потокового

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Benchmark
public int array_stream() {
    // implicitly unboxed
    return Arrays
            .stream(intArray)
            .reduce(0, this::arithmeticOperation);
}
  
@Benchmark
public int array_stream_boxed() {
    // explicitly boxed
    return Arrays
            .stream(intArray)
            .boxed()
            .reduce(0, this::arithmeticOperation);
}
  
@Benchmark
public int list_stream_unbox() {
    // naively unboxed
    return intList
            .stream()
            .mapToInt(Integer::intValue)
            .reduce(0, this::arithmeticOperation);
}
  
@Benchmark
public int list_stream() {
    // implicitly boxed
    return intList
            .stream()
            .reduce(0, this::arithmeticOperation);
}

Здесь упаковка и распаковка связаны не с тем, как хранятся данные (они распакованы в массиве и помещены в список), а с тем, как значения обрабатываются потоком.

Обратите внимание, что в boxed преобразуется IntStream , специализированная реализация Stream, которая имеет дело только с примитивами int , в Stream<Integer> , поток над объектами. Это должно оказать негативное влияние на производительность, но степень зависит от того, насколько хорошо работает анализ выхода .

Так как список является универсальным (то есть не специализированным IntArrayList ), он возвращает Stream<Integer> . Последний метод тестирования вызывает mapToInt , который возвращает IntStream . Это наивная попытка распаковать элементы потока.

арифметика
массив список
за 4,405 4,099
для каждого 4,434 4,707
поток (без коробки) 4,100 4,518
поток (в штучной упаковке) 7,694 7,776

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

(Кстати, все эти разговоры о боксе / распаковке и специализированных реализациях делают меня еще более счастливым, что Project Valhalla продвигается так хорошо .)

Более конкретное следствие этих тестов состоит в том, что для операций с привязкой к ЦП потоковая передача, по-видимому, не требует значительных затрат производительности. Опасаясь значительного недостатка, это приятно слышать.

Сравнение количества элементов

В целом, результаты довольно стабильны при прогонах с различной длиной последовательности (от 50 000 до 50 000 000). С этой целью я проверил нормированную производительность на 1 000 000 элементов в этих прогонах.

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

От 500 000 до 50 000 000 элементов
метод время
array_max_for + 44,3%
array_sum_for + 13,4%
list_max_for + 12,8%

Интересно, что это самые простые итерационные механизмы и операции.

Победителями становятся более сложные итерационные механизмы над простыми операциями:

От 500 000 до 50 000 000 элементов
метод время
array_sum_stream — 84,9%
list_max_stream — 13,5%
list_sum_stream — 7,0%

Это означает, что таблица, которую мы видели выше для 500 000 элементов, выглядит немного иначе для 50 000 000 (нормализовано до 1 000 000 элементов; в миллисекундах):

Максимум сумма арифметика строка
массив список массив список массив список массив список
500’000 элементов
за 0,246 1,400 0,372 1,428 8,810 8,199 99,066 98,650
ручей 1,118 6,544 2,788 7,168 8,200 15,552 104,472 129,978
50’000’000 элементов
за 0,355 1,579 0,422 1,522 8,884 8,313 93,949 97,900
ручей 1,203 3,954 0,421 6,710 8,408 15,723 96,550 117,690

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

отражение

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

Тем не менее, есть еще несколько открытых вопросов. Самое заметное: как насчет параллельных потоков? Затем мне любопытно выяснить, при какой сложности операции я вижу изменение производительности, зависящей от итерации (например, суммы и максимума ), от производительности, не зависящей от итерации (например, арифметики ). Меня также интересует влияние аппаратного обеспечения. Конечно, это изменит цифры, но будут ли качественные различия?

Еще один вывод для меня, что микробенчмаркинг не так сложен. Или я думаю, пока кто-нибудь не укажет на все мои ошибки …

Ссылка: Поток производительности от нашего партнера JCG Николая Парлога в блоге CodeFx .