Как распределяется вычислительный объём на ядрах?
Пример из практики: изучали мы, значит, многопоточность. Для лучшего понимания и погружения нужно было выполнить простую задачу — написать функцию, которая максимально задействует 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(); } }
На первый взгляд кажется, всё верно. Запускаем
Одна функция в своём потоке отрабатывает за пять секунд. Запущенные две функции в двух потоках вместо ожидаемых тех же пяти секунд отрабатывают аж за тридцать три. А в четыре потока на четырёх ядрах аж за пятьдесят три.
Аномалия скрывалась в алгоритме
Мораль
При написании многопоточного кода пристальное внимание приходится уделять поведению сторонних библиотек. Но это вовсе не означает, что стандартная библиотека должна этой участи избегать, она тоже может подбрасывать сюрпризы.
Есть вопрос? Напишите в комментариях!