Привязка потоков (affinity) в Intel® Threading Building Blocks на сопроцессоре Intel® Xeon Phi™

Библиотека Intel® Threading Building Blocks (Intel® TBB) [1] [2] предоставляет высокоуровневые интерфейсы для написания программ, использующих параллельные вычисления. И несмотря на то, что цель этих интерфейсов скрыть от разработчика управление потоками в системе, всё же иногда встречаются задачи, требующие ручного управления. Одна из таких задач - привязка программных потоков к определённым физическим потокам (ядрам) [3]. Так как привязка потоков может помочь улучшить производительность кэша процессора [3] [4] и, как следствие, общую производительность программы, данная тема не может быть не рассмотрена в рамках разговора об Intel TBB. В отличие от OpenMP* [5] [6], библиотека Intel TBB не имеет специальных встроенных средств и инструментов для управления привязкой потоков в системе. Для решения этой задачи может быть использована функциональность Intel TBB task_scheduler_observer [7] [8]. В данной статье я хочу продемонстрировать, как эта функциональность может быть применена для привязки потоков на сопроцессоре Intel Xeon Phi [9].

Так как библиотека Intel TBB не предоставляет средств для привязки потоков, мы будем использовать системный интерфейс (API). Сопроцессор Intel Xeon Phi работает под управлением операционной системы Linux*. Для решения нашей задачи мы можем использовать системный интерфейс sched_setaffinity [10]. Прежде чем мы приступим к изучению функциональности Intel TBB task_scheduler_observer, остановимся на некоторых особенностях привязки потоков и использовании системного интерфейса операционной системы Linux. С одной стороны, системный интерфейс sched_setaffinity выглядит простым для использования: достаточно всего лишь указать необходимую маску привязки для текущего потока и задача решена.

 

int sched_setaffinity(pid_t pid, unsigned int len, unsigned long *mask);
int sched_getaffinity(pid_t pid, size_t cpusetsize, cpu_set_t *mask);

 

Но с другой стороны, операционная система Linux не предоставляет никакого интерфейса для определения размера необходимой маски. Один из способов создать маску нужного размера – это попробовать создать маску какого-нибудь разумного размера и вызвать системный интерфейс для получения маски привязки текущего потока (sched_getaffinity). Если sched_getaffinity вернёт неуспешный код возврата «EINVAL», то это будет означать, что начальная маска имеет недостаточный размер, и мы должны попробовать маску большего размера. Следующий фрагмент программного кода реализует данный алгоритм:

 

cpu_set_t *mask;
int ncpus;
for ( ncpus = sizeof(cpu_set_t)/CHAR_BIT; ncpus < 16*1024 /* some reasonable limit */; ncpus <<= 1 ) {
	mask = CPU_ALLOC( ncpus );
	if ( !mask ) break;
	const size_t size = CPU_ALLOC_SIZE( ncpus );
	CPU_ZERO_S( size, mask );
	const int err = sched_getaffinity( 0, size, mask );
	if ( !err ) break;

	CPU_FREE( mask );
	mask = NULL;
	if ( errno != EINVAL )  break;
}
if ( !mask )
	std::cout << "Warning: Failed to obtain process affinity mask. Thread affinitization is disabled." << std::endl;

Ещё одна особенность состоит в том, что сопроцессор Intel® Xeon Phi™ имеет собственную топологию соответствия между логическими и физическими потоками: логический поток 0 - это физический поток на последнем ядре, а логические потоки 1-4 являются физическими потоками на ядре 0. Более того, существует рекомендация избегать использования логического потока 0 (а лучше, избегать использования всего последнего ядра), т.к. этот поток используется операционной системой и драйверами. Дополнительную информацию по данному вопросу можно найти в рекомендациях [16].

Библиотека Intel TBB предоставаляет класс task_scheduler_observer с парой виртуальных методов, которые позволяют «клиентам» данного класса получать оповещения: когда потоки начинают и заканчивают свою работу в планировщике задач. В нашем случае нам нужны только оповещения, когда потоки начинают работу – это подходящий момент для указания операционной системе, где исполнять данный поток. Давайте попытаемся написать простейший пример для вычисления числа π [11] с использованием функциональности Intel TBB task_scheduler_observer для задания привязки потоков. Для начала, нам необходимо подключить соответствующий заголовочный файл:

 

#include <tbb/task_scheduler_observer.h>

 

В тот момент, когда поток начинает свою работу, вызывается метод on_scheduler_entry. Основная идея - это создать счётчик, который считает, как много потоков уже начало работать. Счётчик поможет нам получить уникальные номера для каждого потока, которые позволят понять, какую маску привязки нам необходимо создать. Так как метод on_scheduler_entry может вызываться одновременно из нескольких потоков, мы должны использовать атомарные операции для корректного подсчёта числа потоков. Атомарные операции реализованы в библиотеке Intel TBB с помощью класса tbb::atomic. Начиная с версии библиотеки Intel TBB 4.2 Update 1, мы можем использовать метод tbb::task_arena::current_slot() для получения уникального номера позиции потока в текущей арене (tbb::task_arena). Следует отметить, что считается хорошей практикой делать привязку потоков в соответствии с маской привязки процесса установленной операционной системой. Мы уже узнали, как получить маску процесса в первом фрагменте программного кода. Таким образом, указатель mask содержит маску процесса, и переменная ncpus содержит длину маски. Следующий фрагмент программного кода получает уникальный номер потока с помощью функциональности Intel TBB task_scheduler_observer:

 

tbb::atomic<int> thread_index;
/*override*/ void on_scheduler_entry( bool ) {
    if ( !mask ) return;

    const size_t size = CPU_ALLOC_SIZE( ncpus );
    const int num_cpus = CPU_COUNT_S( size, mask );
    int thr_idx =
#if USE_TASK_ARENA_CURRENT_SLOT
        tbb::task_arena::current_slot();
#else
        thread_index++;
#endif
#if __MIC__
    thr_idx += 1; // To avoid logical thread zero for the master thread on Intel(R) Xeon Phi(tm)
#endif
    thr_idx %= num_cpus; // To limit unique number in [0; num_cpus-1] range

 

Мы предполагаем, что запрошенное число потоков у библиотеки Intel TBB может быть больше числа физических потоков, доступных в маске процесса. Но нас это не должно сильно волновать, т.к. мы имеет однозначное соответствие с нашим уникальным номером потока и доступными физическими ресурсами в диапазоне [0; num_cpus-1].

Для того чтобы иметь возможность делать привязку потоков с последовательными номерами к различным ядрам (не следует путать с различными физическими потоками, которые могут быть на одном и том же ядре), предлагается использовать переменную pinning_step:

 

// Place threads with specified step
int cpu_idx = 0;
for ( int i = 0, offset = 0; i= num_cpus ) cpu_idx = ++offset; }

 

В результате cpu_idx будет содержать номер бита, который должен быть выставлен в маске потока. Так как мы хотим учитывать маску процесса, то найдём бит в данной маске с заданным номером:

 

// Find index of 'cpu_idx'-th bit equal to 1
int mapped_idx = -1;
while ( cpu_idx >= 0 ) {
    if ( CPU_ISSET_S( ++mapped_idx, size, mask ) )
        --cpu_idx;
}

 

mapped_idx содержит позицию нужного нам бита. Установим данный бит и передадим маску операционной системе, чтобы ОС сделала привязку нашего потока в соответствии с предоставленной маской:

 

cpu_set_t *target_mask = CPU_ALLOC( ncpus );
CPU_ZERO_S( size, target_mask );
CPU_SET_S( mapped_idx, size, target_mask );
const int err = sched_setaffinity( 0, size, target_mask );

if ( err ) {
    std::cout << "Failed to set thread affinity!n";
    exit( EXIT_FAILURE );
}
#if LOG_PINNING
else {
    std::stringstream ss;
    ss << "Set thread affinity: Thread " << thr_idx << ": CPU " << mapped_idx << std::endl;
    std::cerr << ss.str();
}
#endif
CPU_FREE( target_mask );

 

Если мы соберём все фрагменты программных кодов, то получим реализацию класса pinning_observer, осуществляющего привязку потоков:

 

class pinning_observer: public tbb::task_scheduler_observer {
    cpu_set_t *mask;
    int ncpus;

    const int pinning_step;
    tbb::atomic<int> thread_index;
public:
    pinning_observer( int pinning_step=1 ) : pinning_step(pinning_step), thread_index() {
        for ( ncpus = sizeof(cpu_set_t)/CHAR_BIT; ncpus < 16*1024 /* some reasonable limit */; ncpus <<= 1 ) {
            mask = CPU_ALLOC( ncpus );
            if ( !mask ) break;
            const size_t size = CPU_ALLOC_SIZE( ncpus );
            CPU_ZERO_S( size, mask );
            const int err = sched_getaffinity( 0, size, mask );
            if ( !err ) break;

            CPU_FREE( mask );
            mask = NULL;
            if ( errno != EINVAL )  break;
        }
        if ( !mask )
            std::cout << "Warning: Failed to obtain process affinity mask. Thread affinitization is disabled." << std::endl;
    }

/*override*/ void on_scheduler_entry( bool ) {
    if ( !mask ) return;

    const size_t size = CPU_ALLOC_SIZE( ncpus );
    const int num_cpus = CPU_COUNT_S( size, mask );
    int thr_idx =
#if USE_TASK_ARENA_CURRENT_SLOT
        tbb::task_arena::current_slot();
#else
        thread_index++;
#endif
#if __MIC__
    thr_idx += 1; // To avoid logical thread zero for the master thread on Intel(R) Xeon Phi(tm)
#endif
    thr_idx %= num_cpus; // To limit unique number in [0; num_cpus-1] range

        // Place threads with specified step
        int cpu_idx = 0;
        for ( int i = 0, offset = 0; i<thr_idx; ++i ) {
            cpu_idx += pinning_step;
            if ( cpu_idx >= num_cpus )
                cpu_idx = ++offset;
        }

        // Find index of 'cpu_idx'-th bit equal to 1
        int mapped_idx = -1;
        while ( cpu_idx >= 0 ) {
            if ( CPU_ISSET_S( ++mapped_idx, size, mask ) )
                --cpu_idx;
        }

        cpu_set_t *target_mask = CPU_ALLOC( ncpus );
        CPU_ZERO_S( size, target_mask );
        CPU_SET_S( mapped_idx, size, target_mask );
        const int err = sched_setaffinity( 0, size, target_mask );

        if ( err ) {
            std::cout << "Failed to set thread affinity!n";
            exit( EXIT_FAILURE );
        }
#if LOG_PINNING
        else {
            std::stringstream ss;
            ss << "Set thread affinity: Thread " << thr_idx << ": CPU " << mapped_idx << std::endl;
            std::cerr << ss.str();
        }
#endif
        CPU_FREE( target_mask );
    }

    ~pinning_observer() {
        if ( mask )
            CPU_FREE( mask );
    }
};

 

Следующий фрагмент кода демонстрирует один из простейших алгоритмов вычисления числа π с помощью шаблонного алгоритма parallel_reduce из библиотеки Intel TBB:

 

template <typename R, typename S>
R tbb_pi( S num_steps )
{
    const R step = R(1) / num_steps;
    return step * tbb::parallel_reduce( tbb::blocked_range<S>( 0, num_steps ), R(0),
        [step] ( const tbb::blocked_range<S> r, R local_sum ) -> R {
            for ( S i = r.begin(); i < r.end(); ++i ) {
                R x = (i + R(0.5)) * step;
                local_sum += R(4) / (R(1) + x*x);
            }
            return local_sum;
        },
        std::plus<R>()
    );
}

 

Естественно, это не лучший алгоритм ни с точки зрения производительности, ни с точки зрения точности вычислений. Более того, нет никакого смыла вычислять число π заново, т.к. более чем 10 триллионов (1013) цифр данного числа уже вычислено [11]. Но это хороший учебный пример, и он может быть полезен в качестве измерительного теста (benchmark) накладных расходов и масштабируемости системы.

Давайте попробуем сравнить производительность данного примера с использованием привязки потоков и без него. Сопроцессор Intel Xeon Phi имеет несколько сотен физических потоков и создание такого числа потоков занимает заметное время, поэтому мы будем измерять только вычислительное время, не учитывающее время создания потоков. Для того чтобы удостовериться, что все запрошенные потоки создались, также может быть использована функциональность Intel TBB task_scheduler_observer. Реализуем concurrency_tracker, который считает, сколько потоков уже начало работу:

 

class concurrency_tracker: public tbb::task_scheduler_observer {
    tbb::atomic<int> num_threads;
public:
    concurrency_tracker() : num_threads() { observe(true); }
    /*override*/ void on_scheduler_entry( bool ) { ++num_threads; }
    /*override*/ void on_scheduler_exit( bool ) { --num_threads; }

    int get_concurrency() { return num_threads; }
};

 

Специальный цикл «прогрева» необходимо добавить в функцию main перед непосредственным замером времени вычислений:

 

int main(int argc, char* argv[])
{
    const size_t N = 10L * 1000 * 1000 * 1000;
    const int threads = argc > 1 ? atoi( argv[1] ) : tbb::task_scheduler_init::default_num_threads();
    const bool use_pinning = argc > 2 ? atoi( argv[2] ) : false;

    tbb::task_scheduler_init init( threads );

    pinning_observer pinner( 4 /* the number of hyper threads on each core */ );
    pinner.observe( use_pinning );

    // Warmer
    concurrency_tracker tracker;
    while ( tracker.get_concurrency() < threads ) tbb_pi<double>( N );

    tbb::tick_count t0 = tbb::tick_count::now();
    const double pi = tbb_pi<double>( N );
    const double time = (tbb::tick_count::now()-t0).seconds();

    const double eps = 1e-10;
    const double PI = 3.1415926536;

    const double err = fabs(pi/PI-1.0);
    if ( err > eps ) {
        std::cout << "Error: " << err << std::endl;
        return -1;
    }

    std::cout << "Pi = " << pi << " Threads: " << threads << " Time: " << time << " sec. (use_pinning = " << use_pinning << ")" << std::endl;

    // Always disable observation before observers destruction
    tracker.observe( false );
    pinner.observe( false );

    return 0;
}

 

Для компиляции примера установим переменные окружения компилятора Intel C++ Composer XE 2013 SP1:

source /opt/intel/composer_xe_2013_sp1/bin/compilervars.sh intel64

И соберём наш пример для непосредственного исполнения на сопроцессоре Intel Xeon Phi:

icc -o pi.exe -mmic -std=c++11 -tbb -pthread -lrt pi.cpp

Прежде чем запустить собранный пример, нужно скопировать бинарный файл и необходимые библиотеки на сопроцессор Intel Xeon Phi:

scp pi.exe mic0:/tmp
scp /opt/intel/composer_xe_2013_sp1/tbb/lib/mic/libtbb* mic0:/tmp

И запускам пример:

ssh mic0 LD_LIBRARY_PATH=/tmp /tmp/pi.exe [num_threads] [use_pinning]

Я собрал времена исполнения на сопроцессоре Intel® Xeon Phi™ Coprocessor 7120X (16GB, 1.238 GHz, 61 core) для всевозможных значений входных параметров: число потоков (num_threads) в диапазоне [1;244] и включение/отключение использования привязки потоков (use_pinning) в диапазоне [0;1]. Результаты представлены на графике производительности (speedup) и графике эффективности (efficiency) (который, согласно [12], более информативный):

The Pi example charts on Intel(R) Xeon Phi (tm) coprocessor

Оказалось, что результаты не зависят от того, используется ли привязка потоков или нет. В принципе, это было ожидаемо, т.к. приведённая реализация вычисления числа π преимущественно использует только вычислительные ресурсы системы и не зависит от производительности кэша процессора (оптимизация которого и предполагается с помощью подхода привязки потоков [4]).

Был проведён ещё один эксперимент: данный пример был запущен 100 раз на максимальном числе физических потоков с включенной и отключенной привязкой потоков. По полученным результатам были посчитаны статистические величины, такие как математическое ожидание [13] (арифметическое среднее [14]) и стандартное отклонение [15]:

The expected value and the standard deviation formulas

Где µ00 – математическое ожидание и стандартное отклонение с отключенной привязкой потоков, а µ11 – математическое ожидание и стандартное отклонение с включённой привязкой потоков. Как Вы можете видеть, математическое ожидание в обоих случаях почти одинаковое, как и должно быть в соответствии с графиками. Так как разброс значений очень мал, мы не можем оценить его визуально из графиков, но как видно из статистических величин, в случае с использованием привязки потоков стандартное отклонение заметно меньше – на 17-18%.

В заключение я хотел бы добавить предостерегающий совет: не смотря на тот факт, что привязка потоков может помочь улучшить производительность приложений, это не всегда так. Например, наш пример вычисления числа π продемонстрировал независимость по отношению к привязке потоков. Более того, если топология системы неясна, и/или неизвестно, какие ресурсы использует операционная система и драйвера – очень легко использовать привязку потоков для того, чтобы ограничить исполнение потоков на неподходящих ресурсах и вместо улучшения производительности получить обратный эффект. Так же надо быть аккуратнее с привязкой потоков в приложениях, в которых используется несколько библиотек для параллельных вычислений, потому что в такой ситуации ещё сложнее оценить, какие ресурсы доступны в каждый момент времени.

References:

  1. Intel® Threading Building Blocks (Intel® TBB)
  2. Intel® Threading Building Blocks (Intel® TBB) (open source)
  3. Wikipedia: Processor affinity
  4. Linux* Journal: Processor affinity
  5. OpenMP*
  6. OpenMP* Thread Affinity Control
  7. Intel TBB documentation: task_scheduler_observer
  8. Under the hood: Building hooks to explore TBB task scheduler
  9. Intel® Xeon Phi™ Product Family
  10. Fedora Manpages: SCHED_SETAFFINITY(2)
  11. Wikipedia: Pi
  12. Wikipedia: Speedup
  13. Wikipedia: Expected value
  14. Wikipedia: Mean
  15. Wikipedia: Standard deviation
  16. FAQs: Compiler

Пожалуйста, обратитесь к странице Уведомление об оптимизации для более подробной информации относительно производительности и оптимизации в программных продуктах компании Intel.

For more complete information about compiler optimizations, see our Optimization Notice.