Как распределяется вычислительный объём на ядрах?

C___Deep_24.5_site-5020-8af3aa.png

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

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

Итак, запускаем пять таких функций. Напомню, наша функция работает около двух секунд. Это значит, общее время должно получиться около десяти секунд.

Как это проверить?

Запускаем все пять подряд. Время суммируется. Запускаем все пять одновременно, но с разрешением работать только на одном ядре, например командой:

taskset -c 0 ./a.out

Можем даже воспользоваться высокоточным профайлером (шутка конечно же, но его точности нам хватит):

time taskset -c 0 ./a.out

Что у нас должно получиться при пяти потоках и разном количестве доступных ядер:

ядер|секунд
   -|-
   1|10
   2| 5
   3| 3.3
   5| 2

Дальнейшее увеличение доступных ядер ни к чему не приведёт.

Градусник не врёт, поменяйте тело

Приходит вопрос от слушателя «Что за ерунда?». Не получаются такие замеры, хоть ты тресни. Причём не просто не похожи, а отличаются разительно. Получаю код, начинаем разбираться:

int vector_size = 1'000;
int repeat = 100'000;

int main(int argc, char *argv[]) 
{      
 int threads_count = start_parsing(argc,argv);
 std::vector<std::thread> tv;
 tv.reserve(threads_count);
 for (int i = 0; i < threads_count; i++) {
   tv.push_back(std::thread([](){
     std::vector<int> v;
     v.resize(vector_size);
     std::iota(v.begin(),v.end(), 0);
     for (int i = 0; i < repeat; i++) {
       std::random_shuffle(v.begin(),v.end());
     }
   }));
 }
 std::cout << tv.size() << '\n';
 for (auto& t : tv) {
   t.join();
 }
}

На первый взгляд кажется, всё верно. Запускаем threads_count потоков с функциями, которые оголтело перемешивают элементы вектора. Замеряем без ограничений по ядрам, четыре ядра точно есть.

Одна функция в своём потоке отрабатывает за пять секунд. Запущенные две функции в двух потоках вместо ожидаемых тех же пяти секунд отрабатывают аж за тридцать три. А в четыре потока на четырёх ядрах аж за пятьдесят три.

Аномалия скрывалась в алгоритме std::random_shuffle, который в используемой библиотеке использовал std::rand в качестве генератора случайных чисел. Замена его на std::shuffle проблему полностью решила и теперь в независимости от количества потоков и наличии свободных ядер время держится в районе пяти секунд.

Мораль

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

Есть вопрос? Напишите в комментариях!

Автор
0 комментариев
Для комментирования необходимо авторизоваться