пятница, 2 марта 2018 г.

С++: Thread affinity, thread pools и performance

Thread pool и affinity

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

Однако, если мы говорим о серверах, и о ситуациях, когда thread pool используется для параллельного выполнения однотипных задач - AIO, например, или некий процессинг - то в подобных случаях ожидается именно кратное ускорение за счет распараллеливания.

Конечно, здесь вступает в игру механизм синхронизации - как параллелящихся задач, так и самого пула (обычно его очереди). Мьютексы сами по себе дают точки сериализации и приводят к тому, что выигрыш от паралеллизма не кратен числу тредов/числу физических ядер.

Но речь в данном случае не о реализациях пулов тредов, а об affinity.

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

Приведу один пример.

Допустим, у вас 8 ядер. Вы запускаете процесс, создающий пул из 8 тредов, и на эти 8 тредов запускаете потоком одинаковые задачи. 

Пока треды распределены по ядрам более-менее равномерно - все в шоколаде. Степень параллелизма 6-8. Если вы хорошо написали сами выполняющиеся задачи - то есть они в достаточной степени автономны и не требуют синхронизации, а реализация пула тредов у вас lock-free - выигрыш очевиден.

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

Представили?

Это трындец. Треды, которые должны быть параллельными - по факту выполняются на одном ядре последовательно.

Что в итоге?
А в итоге восьмикратное (минимум) замедление (минимум потому, что собственные механизмы очереди пула дополнительно замедлены тем, что сошлись на одном ядре вместе с выполняющимися процессами).

В подобной ситуации thread affinity спасет гигантов мысли.

Одно маленькое НО: это надо реализовывать на уровне библиотеки thread pool.

Thread pool with affinity

В предыдущей статье приведен очень простенький пример, который мало применим на практике. На самом деле нам нужно выполнять processor binding на уровне библиотеки thread pool.

Для начала, постановка задачи.

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

То есть, нам необходимо обеспечить привязку тредов по ядрам с использованием Round-Robin.

Задача кажется очевидной, однако несколько осложняется тем, что не во всех системах идентификаторы ядер идут по порядку - 0,1,2,3,4,5 ...... и так далее.

Например:





Это SPARC.

То есть, если в сервере несколько материнских плат, то нумерация ядер может идти и не по порядку.

Что ж, ничего сложного. Немного видоизменим алгоритм для Солярис, только и всего.

Фрагмент библиотеки thread pool:
 #ifdef AFFINITY  
 #if defined __sun__  
 #include <sys/types.h>  
 #include <sys/processor.h>  
 #include <sys/procset.h>  
 #include <unistd.h>     /* For sysconf */  
 #elif __linux__  
 #include <cstdio>     /* For fprintf */  
 #include <sched.h>  
 #endif  
 #endif  
   
 ...  
   
  // Init thread pool  
  void init() {  
   #if (defined __sun__ || defined __linux__) && defined AFFINITY    
   std::size_t v_cpu = 0;  
   std::size_t v_cpu_max = std::thread::hardware_concurrency() - 1;  
   #endif  
   
   #if defined __sun__ && defined AFFINITY  
   std::vector<processorid_t> v_cpu_id;     /* Struct for CPU/core ID */  
   
   processorid_t i, cpuid_max;  
   cpuid_max = sysconf(_SC_CPUID_MAX);  
   for (i = 0; i <= cpuid_max; i++) {  
     if (p_online(i, P_STATUS) != -1)     /* Get only online cores ID */  
       v_cpu_id.push_back(i);  
   }  
   #endif  
   
   for (std::size_t i = 0; i < m_threads.size(); ++i) {  
   
      #if (defined __sun__ || defined __linux__) && defined AFFINITY  
      if (v_cpu > v_cpu_max) {  
           v_cpu = 0;  
      }  
   
      #ifdef __sun__  
      processor_bind(P_LWPID, P_MYID, v_cpu_id[v_cpu], NULL);  
      #elif __linux__  
      cpu_set_t mask;  
      CPU_ZERO(&mask);  
      CPU_SET(v_cpu, &mask);  
      pthread_t thread = pthread_self();  
      if (pthread_setaffinity_np(thread, sizeof(cpu_set_t), &mask) != 0) {  
           fprintf(stderr, "Error setting thread affinity\n");  
      }  
      #endif  
   
      ++v_cpu;  
      #endif  
   
    m_threads[i] = std::thread(ThreadWorker(this, i));  
   }  
  }  
   

То есть мы запрашиваем у системы фактические ID онлайн-ядер процессоров, заталкиваем их в вектор, и потом, пробегая по вектору, задаем привязку каждого треда к ядру.

Все просто.

В результате наша программа с 8 тредами будет распределена по ядрам вот таким образом:


lwp id 25633/1: 19 - это родительский процесс, запускающий пул.

Результаты тестирования показали 3-5-кратное улучшение латентности в сравнении с пулом без affinity в данной конкретной задаче (с 15 мс до 3 мс среднего времени отклика).