Я пытаюсь определить, в каких случаях pthreads рабочих нагрузок становятся полезными. На данный момент я обнаружил, что рабочие нагрузки должны занимать около 3 мс, чтобы pthreads положительно влияли на общий прогресс (в тестовой системе Alderlake).
Это правильный порядок величин?
Результаты сравнительного анализа приведены ниже:
BM_dispatch<dispatch>/16/process_time/real_time 1.37 ms 1.37 ms 513
BM_dispatch<dispatch>/32/process_time/real_time 2.75 ms 2.75 ms 252
BM_dispatch<dispatch>/48/process_time/real_time 4.15 ms 4.15 ms 169
BM_dispatch<dispatch>/64/process_time/real_time 5.52 ms 5.52 ms 126
BM_dispatch<dispatch>/80/process_time/real_time 6.89 ms 6.89 ms 101
BM_dispatch<dispatch>/96/process_time/real_time 8.26 ms 8.26 ms 84
BM_dispatch<dispatch>/112/process_time/real_time 9.62 ms 9.62 ms 72
BM_dispatch<dispatch_pthread>/16/process_time/real_time 2.16 ms 4.18 ms 359
BM_dispatch<dispatch_pthread>/32/process_time/real_time 3.76 ms 7.38 ms 200
BM_dispatch<dispatch_pthread>/48/process_time/real_time 3.67 ms 7.18 ms 150
BM_dispatch<dispatch_pthread>/64/process_time/real_time 4.30 ms 8.44 ms 163
BM_dispatch<dispatch_pthread>/80/process_time/real_time 4.38 ms 8.60 ms 176
BM_dispatch<dispatch_pthread>/96/process_time/real_time 4.93 ms 9.69 ms 146
BM_dispatch<dispatch_pthread>/112/process_time/real_time 5.31 ms 10.5 ms 126
Я тестирую две функции dispatch
и dispatch_pthread
при разных размерах рабочей нагрузки. Функция выполняет ту же общую работу, но dispatch_pthreads
делит работу между двумя потоками. Когда время выполнения составляет около 1 мс, pthreads бесполезны. При рабочей нагрузке около 8 мс два потока pthread примерно в два раза быстрее, чем один поток.
Полная программа ниже:
void find_max(const float* in, size_t eles, float* out) {
float max{0};
for (size_t i = 0; i < eles; ++i) {
if (in[i] > max) max = in[i];
}
*out = max;
}
float dispatch(const float* inp, size_t rows, size_t cols, float* out) {
for (size_t row = 0; row < rows; row++) {
find_max(inp + row * cols, cols, out + row);
}
}
struct pthreadpool_context {
const float* inp;
size_t rows;
size_t cols;
float* out;
};
void* work(void* ctx) {
const pthreadpool_context* context = (pthreadpool_context*)ctx;
dispatch(context->inp, context->rows, context->cols, context->out);
return NULL;
}
float dispatch_pthread(const float* inp, size_t rows, size_t cols, float* out) {
pthread_t thread1, thread2;
size_t rows_per_thread = rows / 2;
const pthreadpool_context context1 = {inp, rows_per_thread, cols, out};
const pthreadpool_context context2 = {inp + rows_per_thread * cols,
rows_per_thread, cols,
out + rows_per_thread};
pthread_create(&thread1, NULL, work, (void*)&context1);
pthread_create(&thread2, NULL, work, (void*)&context2);
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
}
template <auto F>
void BM_dispatch(benchmark::State& state) {
std::random_device rnd_device;
std::mt19937 mersenne_engine{rnd_device()};
std::normal_distribution<float> dist{0, 1};
auto gen = [&]() { return dist(mersenne_engine); };
const size_t cols = 1024 * state.range(0);
constexpr size_t rows = 100;
std::vector<float> inp(rows * cols);
std::generate(inp.begin(), inp.end(), gen);
std::vector<float> out(rows);
for (auto _ : state) {
F(inp.data(), rows, cols, out.data());
}
}
BENCHMARK(BM_dispatch<dispatch>)
->DenseRange(16, 112, 16)
->MeasureProcessCPUTime()
->UseRealTime()
->Unit(benchmark::kMillisecond);
BENCHMARK(BM_dispatch<dispatch_pthread>)
->DenseRange(16, 112, 16)
->MeasureProcessCPUTime()
->UseRealTime()
->Unit(benchmark::kMillisecond);
BENCHMARK_MAIN();
Программа компилируется с оптимизацией O2
с помощью gcc 13.2.0 в Ubuntu 22.04 с ядром 5.15.
... или стандартные политики выполнения: std::max_element(std::execution::par, ...
извините, хороший момент. Я хотел использовать примитивы потоков низкого уровня, чтобы увидеть их поведение при масштабировании, но вы правы, код представляет собой смесь C и C++.
@fabian std::thread
— примитив довольно низкого уровня. Он добавляет синтаксический сахар поверх API-интерфейса, специфичного для платформы, но в остальном он делает то же самое.
просто если это интересно: я пробовал с std::thread
, и время выполнения очень близко к pthreads. Спасибо за предложение.
@fabian Затем вы также можете попробовать использовать std::max_element
с std::execution::par
или std::execution::par_unseq
, чтобы проверить, работает ли встроенный пул потоков лучше.
Если вы хотите ускорить операции масштаба в микросекунды, существует SIMD, но он подходит не для всех рабочих нагрузок, или вы можете уже создать потоки, занятые ожиданием работы, а не создавать и присоединяться к потокам. Если мне не изменяет память, время снятия блокировок и т. д. составляет порядка 1 мкс, то есть на порядки быстрее, чем создание и объединение потоков.
Большое спасибо, @SimonGoater. Вышеупомянутая рабочая нагрузка представляет собой простой манекен, который я могу опробовать на разных арках, поэтому в этой демонстрации я хочу избежать SIMD. Предложение поработать с уже запущенными тредами очень интересно, попробую это проверить!
Частое создание потока происходит довольно медленно и в любом случае неэффективно. Вам действительно следует избегать этого ради производительности, а также для снижения нагрузки на ресурсы ОС. Обычный способ распараллеливания кодов — создать пул рабочих потоков и затем отправить им задачи (обычно 1 задача на каждого работника для параллельных циклов). Обратите внимание, что вам не следует измерять время создания пула в тестах, поскольку оно редко является узким местом в реальных приложениях и длится более 1 секунды (при условии, что пул часто используется повторно).
Делать это в pthreads затруднительно. С потоками C++ это немного проще, но все равно не так уж и тривиально. Вы можете использовать OpenMP (включенный во все основные компиляторы), чтобы сделать это очень легко: #pragma omp parallel for
для неловко параллельных циклов или #pragma omp parallel for reduction(...)
для сокращений. OpenMP создает пул потоков, равный количеству потоков, поддерживаемых машиной, и перерабатывает их в максимально возможной степени. Поскольку многие вычислительные библиотеки уже используют/поддерживают его (например, Eigen, OpenBLAS, OpenCV), дополнительные накладные расходы на создание потоков OpenMP практически равны нулю.
(однако обратите внимание, что количество потоков в разделе должно оставаться прежним, в противном случае реализация может возобновить все потоки, что может быть дорогостоящим - также обратите внимание, что создание потока в цикле может быть неэффективным на некоторых платформах, таких как многоядерные и в этом случае может быть более эффективно использовать дерево порождения, которое еще сложнее реализовать, не говоря уже о том, что с помощью OpenMP легко привязать потоки к ядру и заботиться о платформах NUMA, в то время как с pthreads или потоками C++ это непросто. -- вот почему OpenMP является стандартом многопоточности на всех современных платформах HPC)
Задача фрейма: если у вас есть задача, требующая менее миллисекунды, насколько важно на самом деле ускорить ее выполнение? С этой точки зрения, рабочая нагрузка, вероятно, должна быть намного больше, чем мс, чтобы дополнительная сложность, связанная с многопоточностью, была оправданной, независимо от скорости, которую она обеспечивает.
Поток pthread GLIBC под Linux поддерживает пул стеков. Этот пул пуст при запуске процесса. Когда потоки создаются, а затем завершаются, стеки не освобождаются, а сохраняются в пуле для повторного использования последующими потоками. Размер стека по умолчанию составляет 8 МБ виртуальной памяти, а размер пула — 40 МБ. Это означает, что по умолчанию для повторного использования можно кэшировать до 5 стеков.
Как следствие, создание первых двух потоков происходит медленнее, поскольку стеки еще не распределены. А вот для последующих это происходит быстрее, потому что стеки выбираются из пула. Но создание потока — это не только выделение стека, но и множество других вещей, таких как инициализация TLS, настройка защитной страницы (для обнаружения переполнения стека)…
Следовательно, лучше вынести создание потока за рамки эталонного измерения. То есть, как было замечено в комментариях, сначала следует создать рабочие потоки.
Вот фрагмент кода pthread , касающийся распределения стеков из внутреннего пула:
/* Maximum size in kB of cache. */
static size_t stack_cache_maxsize = 40 * 1024 * 1024; /* 40MiBi by default. */
[...]
/* Get a stack frame from the cache. We have to match by size since
some blocks might be too small or far too large. */
static struct pthread *
get_cached_stack (size_t *sizep, void **memp)
{
size_t size = *sizep;
struct pthread *result = NULL;
list_t *entry;
lll_lock (stack_cache_lock, LLL_PRIVATE);
/* Search the cache for a matching entry. We search for the
smallest stack which has at least the required size. Note that
in normal situations the size of all allocated stacks is the
same. As the very least there are only a few different sizes.
Therefore this loop will exit early most of the time with an
exact match. */
list_for_each (entry, &stack_cache)
{
struct pthread *curr;
[...]
Интересный. Но выделение (и ошибка страниц) стека потоков — это лишь малая часть создания нового потока. Внутри ядра также много работы по распределению новой задачи. Вынесение создания потоков за пределы эталонного теста имеет смысл только в том случае, если ваш реальный вариант использования использует пул потоков (повторное использование целых потоков, а не только распределение их стека), что, как указывают комментарии под вопросом, может быть чем-то, о чем ОП не знал. .
Спасибо за подробный ответ. Я принимаю это (также чтобы закрыть ветку), но хочу выразить свою благодарность Саймону Гоутеру и Джерому Ричарду, поскольку они уже отточили исходную проблему.
Спасибо за все комментарии и ответы. Основная проблема исходного кода — создание потоков в пределах контрольного времени.
Чтобы дать исчерпывающий ответ, ниже приведены результаты тестов с использованием пула потоков.
Я создал пул потоков по принципу Пул потоков в C++11. При использовании двух потоков задержка всей операции действительно сокращается вдвое по сравнению с однопоточным кодом, уже для рабочих нагрузок, измеряемых микросекундами.
Полные тайминги:
-------------------------------------------------------------------------------------------
Benchmark Time CPU Iterations
-------------------------------------------------------------------------------------------
BM_dispatch<dispatch>/4/process_time/real_time 0.341 ms 0.341 ms 2062
BM_dispatch<dispatch>/8/process_time/real_time 0.691 ms 0.691 ms 1007
BM_dispatch<dispatch>/12/process_time/real_time 1.04 ms 1.04 ms 672
BM_dispatch<dispatch>/16/process_time/real_time 1.39 ms 1.39 ms 500
BM_dispatch<dispatch>/20/process_time/real_time 1.75 ms 1.75 ms 399
BM_dispatch<dispatch>/24/process_time/real_time 2.11 ms 2.11 ms 331
BM_dispatch<dispatch>/28/process_time/real_time 2.48 ms 2.48 ms 284
BM_dispatch<dispatch>/32/process_time/real_time 2.84 ms 2.84 ms 246
BM_dispatch_threadpool/4/process_time/real_time 0.176 ms 0.353 ms 3933
BM_dispatch_threadpool/8/process_time/real_time 0.356 ms 0.717 ms 1947
BM_dispatch_threadpool/12/process_time/real_time 0.522 ms 1.05 ms 1308
BM_dispatch_threadpool/16/process_time/real_time 0.713 ms 1.43 ms 1000
BM_dispatch_threadpool/20/process_time/real_time 0.880 ms 1.77 ms 795
BM_dispatch_threadpool/24/process_time/real_time 1.04 ms 2.10 ms 650
BM_dispatch_threadpool/28/process_time/real_time 1.22 ms 2.45 ms 577
BM_dispatch_threadpool/32/process_time/real_time 1.40 ms 2.82 ms 504
BM_dispatch<dispatch>
измеряет однопоточный код, как указано выше. BM_dispatch_threadpool
измеряет задержки, когда работа передается в уже созданный пул потоков с двумя потоками.
Примечание (не связанное с производительностью): раз вы отметили C++: почему вы не используете
std::thread
s?