Управление режимами вычислений с плавающей запятой при использовании Intel® Threading Building Blocks

В Intel® Threading Building Blocks (Intel® TBB) 4.2, обновление 4, появилась расширенная поддержка управления параметрами вычислений с плавающей запятой. Теперь эти параметры можно указать при вызове большинства параллельных алгоритмов (включая flow::graph). В этом блоге мне бы хотелось рассказать о некоторых особенностях, новых функциях и общей поддержке вычислений с плавающей запятой в Intel TBB. Этот блог не посвящен общей поддержке вычислений с плавающей запятой в ЦП. Если вы незнакомы с поддержкой вычислений с плавающей запятой в ЦП, рекомендую начать с раздела Операции с плавающей запятой в справочном руководстве Intel® C++ Compiler. Для получения дополнительных сведений о сложностях арифметики с плавающей запятой рекомендую классику “Все, что необходимо знать об арифметике с плавающей запятой”.

В Intel TBB предлагается два способа, при помощи которых можно задать нужные параметры вычислений с плавающей запятой для задач, выполняемых планировщиком задач Intel TBB:

  1. Когда планировщик задач инициализируется для заданного потока приложения, он получает текущие параметры вычислений с плавающей запятой этого потока;
  2. У класса task_group_context есть метод для получения текущих параметров вычислений с плавающей запятой.

Рассмотрим первый подход. Такой подход, по сути, является неявным: планировщик задач всегда и безусловно получает параметры вычислений с плавающей запятой в момент своей инициализации. Затем сохраненные параметры используются для всех задач, связанных с этим планировщиком задач. Другими словами, такой подход можно рассматривать как свойство планировщика задач. Поскольку это свойство планировщика задач, мы можем применять и управлять параметрами вычислений с плавающей запятой в нашем приложении двумя способами:

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

Я попробую показать некоторые особенности в следующих примерах:

Обозначения:

  • “fp0”, “fp1” and “fpx” – состояния, описывающие параметры вычислений с плавающей запятой;
  • “set_fp_settings( fp0 )” и “set_fp_settings( fp1 )” – указание параметров вычислений с плавающей запятой для текущего потока;
  • “get_fp_settings( fpx )” – получение параметров вычислений с плавающей запятой из текущего потока и сохранение этих параметров в “fpx”.

Пример #1. Планировщик задач по умолчанию.

// Suppose fp0 is used here.
// Every Intel TBB algorithm creates a default task scheduler which also captures floating-point
// settings when initialized.
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // fp0 will be used for all iterations on any Intel TBB worker thread.
} );
// There is no way anymore to destroy the task scheduler on this thread.

Пример #2. Настраиваемый планировщик задач.

// Suppose fp0 is used here.
tbb::task_scheduler_init tbb_scope;
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // fp0 will be used for all iterations on any Intel TBB worker thread.
} );

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

Пример #3. Повторная инициализация планировщика задач.

// Suppose fp0 is used here.
{
    tbb::task_scheduler_init tbb_scope;
    tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
        // fp0 will be used for all iteration on any Intel TBB worker thread.
    } );
} // the destructor calls task_scheduler_init::terminate() to destroy the task scheduler
set_fp_settings( fp1 );
{
    // A new task scheduler will capture fp1.
    tbb::task_scheduler_init tbb_scope;
    tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
        // fp1 will be used for all iterations on any Intel TBB worker 
        // thread.
    } );
}

Пример #4. Еще один поток.

void thread_func();
int main() {
    // Suppose fp0 is used here.
    std::thread thr( thread_func );
    // A default task scheduler will capture fp0
    tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
        // fp0 will be used for all iterations on any Intel TBB worker thread.
    }
    thr.join();
}
void thread_func() {
    set_fp_settings( fp1 );
    // Since it is another thread, Intel TBB will create another default task scheduler which will
    // capture fp1 here. The new task scheduler will not affect floating-point settings captured by
    // the task scheduler created on the main thread.
    tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
        // fp1 will be used for all iterations on any Intel TBB worker thread.
    }
}

Обратите внимание, что Intel TBB может повторно использовать одни и те же рабочие потоки для обоих parallel_for, несмотря на то, что они вызваны из разных потоков. При этом гарантируется, что все итерации parallel_for в главном потоке будут использовать fp0, а все итерации второго parallel_for — fp1.

Пример #5. Изменение параметров вычислений с плавающей запятой в потоке пользователя.

// Suppose fp0 is used here.
// A default task scheduler will capture fp0.
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // fp0 will be used for all iterations on any Intel TBB worker thread.
} );
set_fp_settings( fp1 );
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // fp0 will be used despite the floating-point settings are changed before Intel TBB parallel
    // algorithm invocation since the task scheduler has already captured fp0 and these settings
    // will be applied to all Intel TBB tasks.
} );
// fp1 is guaranteed here.

Второй parallel_for оставит fp1 неизменным в пользовательском потоке (несмотря на то, что для всех итераций используется fp0), поскольку в Intel TBB гарантируется отсутствие изменений параметров вызывающего потока вызовом любого параллельного алгоритма Intel TBB, даже если алгоритм выполняется с другими параметрами.

Пример #6. Изменение параметров вычислений с плавающей запятой в задаче Intel TBB.

// Suppose fp0 is used here.
// A default task scheduler will capture fp0
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    set_fp_settings( fp1 );
    // Setting fp1 inside the task will lead undefined behavior. There are no guarantees about
    // floating-point settings for any following tasks of this parallel_for and other algorithms.
} );
// No guarantees about floating-point settings here and following algorithms.

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

// Suppose fp0 is used here.
// A default task scheduler will capture fp0
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    get_fp_settings( fpx );
    set_fp_settings( fp1 );
    // ... some calculations.
    // Restore captured floating-point settings before the end of the task.
    set_fp_settings( fpx );
}
// fp0 is guaranteed here.

Для решения большей части проблем можно использовать подход с применением планировщика задач для управления параметров вычислений с плавающей запятой. Но представьте себе ситуацию, когда для каждой из двух частей вычисления требуются разные параметры. Разумеется, можно использовать подходы, показанные в примерах 3 и 4. Но при этом могут возникнуть определенные затруднения:

  1. Реализация затруднена: в примере 3 невозможно управлять жизненным циклом объекта планировщика задач, а в примере 4 может потребоваться синхронизация между потоками;
  2. Влияние на производительность: в примере 3 нужно заново инициализировать планировщик задач, тогда как ранее этого не требовалось, а в примере 4 может возникнуть проблема избыточной подписки.

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

В Intel TBB 4.2 U4 появился новый подход на основе task_group_context: функциональность task_group_context была расширена для управления параметрами вычислений с плавающей запятой для задач, связанных с ним, с помощью нового метода

void task_group_context::capture_fp_settings();

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

Пример #7. Задание параметров вычислений с плавающей запятой для определенного алгоритма.

// Suppose fp0 is used here.
// The task scheduler will capture fp0.
task_scheduler_init tbb_scope;
tbb::task_group_context ctx;
set_fp_settings( fp1 );
ctx.capture_fp_settings();
set_fp_settings( fp0 );
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // In spite of the fact the task scheduler captured fp0 when initialized and the parallel
    // algorithm is called from thread with fp0, fp1 will be here for all iterations on any
    // Intel TBB worker  thread since task group context (with captured fp1) is specified for this
    // parallel algorithm.
}, ctx );

Пример 7 не особенно интересен, поскольку можно добиться такого же результата, если указать fp1 до инициализации планировщика задач. Рассмотрим гипотетическую проблему, когда для двух частей вычисления требуются разные параметры. Эту проблему можно решить так:

Пример #8. Задание параметров вычислений с плавающей запятой для разных частей вычислений.

// Suppose fp0 is used here.
// The task scheduler will capture fp0.
task_scheduler_init tbb_scope;
tbb::task_group_context ctx;
set_fp_settings( fp1 );
ctx.capture_fp_settings();
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // In spite of the fact that floating-point settings are fp1 on the main thread, fp0 will be
    // here for all iterations on any Intel TBB worker thread since the task scheduler captured fp0
    // when initialized.
} );
// fp1 will be used here since TBB algorithms do not change floating-point settings which were set
// before calling.
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // fp1 will be here since the task group context with captured fp1 is specified for this
    // parallel algorithm.
}, ctx );
// fp1 will be used here.

Я уже продемонстрировал одно свойство подхода на основе контекста группы задач в примерах 7 и 8: заданные таким способом параметры имеют более высокий приоритет, чем параметры, заданные с помощью планировщика задач, когда контекст указывается для параллельного алгоритма Intel TBB. При этом подходе наследуется еще одно свойство: вложенные параллельные алгоритмы наследуют параметры вычислений с плавающей запятой из контекста группы задач, указанного для внешнего параллельного алгоритма.

Пример #9. Вложенные параллельные алгоритмы.

// Suppose fp0 is used.
// The task scheduler will capture fp0.
task_scheduler_init tbb_scope;
tbb::task_group_context ctx;
set_fp_settings( fp1 );
ctx.capture_fp_settings();
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // fp1 will be here
    tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
        // Although the task group context is not specified for the nested parallel algorithm and
        // the task scheduler has captured fp0, fp1 will be here.
    }, ctx );
} );
// fp1 will be used here.

Если нужно использовать планировщик задач внутри вложенного алгоритма, можно использовать контекст изолированной группы задач:

Пример #10. Вложенный параллельный алгоритм с изолированным контекстом группы задач.

// Suppose fp0 is used.
// The task scheduler will capture fp0.
task_scheduler_init tbb_scope;
tbb::task_group_context ctx;
set_fp_settings( fp1 );
ctx.capture_fp_settings();
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // fp1 will be used here.
    tbb::task_group_context ctx2( tbb::task_group_context::isolated );
    tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
        // ctx2 is an isolated task group context so it will have fp0 inherited from the task
        // scheduler. That’s why fp0 will be used here.
    }, ctx2 );
}, ctx );
// fp1 will be used here.

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

Основные принципы параметров вычислений с плавающей запятой можно объединить в следующий список:

  • Эти параметры можно задать для всех параллельных алгоритмов Intel TBB с помощью планировщика задач или для отдельных алгоритмов Intel TBB с помощью контекста группы задач;
  • Параметры, полученные контекстом группы задач, имеют приоритет над параметрами, полученными при инициализации планировщика задач;
  • По умолчанию все вложенные алгоритмы наследуют такие параметры с внешнего уровня, если не указан ни контекст группы задач с полученными параметрами, ни изолированный контекст группы задач;
  • При вызове параллельного алгоритма Intel TBB параметры вычислений с плавающей запятой вызывающего потока не изменяются, даже если алгоритм выполняется с другими параметрами;
  • Параметры, заданные после инициализации планировщика задач, недоступны для параллельных алгоритмов Intel TBB, если не используется подход с контекстом группы задач или не выполняется повторная инициализация планировщика задач;
  • Пользовательский код в задаче не должен менять эти параметры либо должен восстанавливать прежние параметры до окончания задачи.

P.S. Отложенный планировщик задач получает параметры с плавающей запятой при вызове метода инициализации.

Пример #11: Явная инициализация планировщика задач.

set_fp_settings( fp0 );
tbb::task_scheduler_init tbb_scope( task_scheduler_init::deferred );
set_fp_settings( fp1 );
init.initialize();
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    // The task scheduler is declared when fp0 is set but it will capture fp1 since it is
    // initialized when fp1 is set.
} );
// fp1 will used be here.

P.P.S. Будьте осторожны, если вы используете функцию автоматической записи в планировщике задач. Она не будет работать, если вызов вашей функции осуществляется внутри другого параллельного алгоритма Intel TBB.

Пример #12. Еще одно предупреждение: берегитесь библиотечных функций.

Фрагмент кода 1. Слегка измененный пример 1. Это работоспособный код, здесь нет ошибок.

set_fp_settings( fp0 );
// Run with the hope that Intel TBB parallel algorithm will create a default task scheduler which
// will also capture floating-point settings when initialized.
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {...} );

Фрагмент кода 2. Достаточно вызвать фрагмент кода 1 как библиотечную функцию.

set_fp_settings( fp1 );
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> & ) {
    call “code snippet 1”;
}
// Possibly, you will want to have fp1 here but see the second bullet below.

Этот пример выглядит вполне безобидным, поскольку фрагмент кода 1 задаст нужные параметры и будет выполнять вычисления с fp0. Но в этом примере есть две проблемы:

  1. К моменту вызова фрагмента кода 1 планировщик задач уже будет инициализирован и уже получит fp1. Поэтому фрагмент кода 1 будет выполнять вычисления с fp1, не учитывая параметры fp0;
  2. Изоляция пользовательских параметров вычислений с плавающей запятой нарушается, поскольку фрагмент кода 1 изменяет эти параметры внутри задачи Intel TBB, но не восстанавливает первоначальные параметры. Поэтому нет никаких гарантий относительно параметров вычислений с плавающей запятой после выполнения параллельного алгоритма Intel TBB во фрагменте кода 2.

Фрагмент кода 3. Исправленное решение.

Исправим фрагмент кода 1:

// Capture the incoming fp settings.
get_fp_settings( fpx );
set_fp_settings( fp0 );
tbb::task_group_context ctx;
ctx.capture_fp_settings();
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> &r ) {
    // Here fp0 will be used for all iterations on any Intel TBB worker thread.
}, ctx );
// Restore fp settings captured before setting fp0.
set_fp_settings( fpx );

Фрагмент кода 2 остается неизменным.

set_fp_settings( fp1 );
tbb::parallel_for( tbb::blocked_range<int>( 1, 10 ), []( const tbb::blocked_range<int> &r ) {
    call “fixed code snip 1”.
} );
// fp1 will be used here since the “fixed code snippet 1” does not change the floating-point
// settings visible to “code snippet 2”.

 

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