Многопоточность: производительность map/reduce

В очередной раз профайля одну программу, обратил внимание на большой spin time (на мьютексе) в достаточно неожиданном месте, а именно в QtConcurrent::blockingMappedReduced (и в подобных). Пришлось сделать стенд и сравнить с TBB (спойлер: TBB нигде не хуже, а есть где и прямо вот в разы лучше).

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

Соответственно, я и тестировал задачи похожие по размеру: 50 задач, время исполнения одной ~0.4, ~1.0 и ~3.0 миллисекунды (в качестве задачи прекрасно подошла чексумма fnv64 на 400, 1000, 3000 килобайт данных).

50 таких задач исполняется быстро, поэтому фигачилось в цикле из N повторений, как-то в таком духе:

for (int i = 0; i < repeat->value(); i++)
   valp = QtConcurrent::blockingMappedReduced(jobs, singleTask, fnvReduce);

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

 

Вся конструкция запускалась на i9-7960x с динамической частотой (я знаю, что бенчмаркать так не очень правильно, но меня интересует поведение у пользователя, а пользователь не будет убирать управление питанием у CPU), оно разогнано до 4.6/4.3Ghz (1 ядро/все ядра, промежуточные значения не очень важны).

Вот результат сведенный в график:

Что мы тут видим, от хорошего к плохому:

  1. Есть изломы (рост производительности), хорошо видимые при переходе от 16 к 17 потокам и от 21 к 25 потокам. Объяснение очень простое: всего задач 50. При 16 потоках - два потока сделали по 4 задачи, остальные по 3. При 17-ти потоках - всем потокам досталось по три задачи, соотв. время улучшится на ~четверть. Аналогичная история при переходе 21-25, от "3 задачи у некоторых потоков" к "у всех по две".
  2. Для "3-миллисекундной" задачи все хорошо, скейлинг близок к линейному (в 12.7 раза для 16 потоков, в 23 раза для 32), QtConcurrent и TBB работают практически одинаково.
  3. Для "1-миллисекундной задачи" TBB работает сильно лучше, чем QtConcurrent, но у QtConcurrent график хотя бы растет вверх.
  4. Для "0.4-миллисекундной задачи" TBB работает хорошо, а QtConcurrent - плохо: начиная с 11 потоков производительность начинает падать.
    Это интересная штука и я ее исследовал отдельно (в профайлере), придя к следующим выводам:
    • Имеет место, как бы это сказать вежливо, насыщение QMutex.
    • Отчего потоки начинают простаивать, ожидая друг друга.
    • Нагрузки на CPU нет.
    • И процессор снижает частоту.
    • В результате содержательная часть работы занимает у QtConcurrent вдвое больше времени по wall clock, это не говоря о том, что простой на mutex - около 60% времени (у TBB оверхед тоже есть и немалый, но это 30% а не 60).

Вывод очевидный: кое-какие места в одной программе надо переделать на TBB. Буквально одно-два. Большого эффекта это не даст, эти места не так и много жрут, но

  • Там и работы - несколько строчек кода
  • А выдача профайлера станет менее отвратительной.