Другое

Руководство по производительности TBB Affinity Partitioner

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

Как продемонстрировать измеримую разницу в производительности между tbb::affinity_partitioner и tbb::auto_partitioner в Intel Threading Building Blocks?

Я пытаюсь воспроизвести преимущества производительности tbb::affinity_partitioner, упомянутые в документации Intel, где утверждается, что для простых вычислений, таких как a[i] += b[i] с варьируемыми размерами векторов, возможно ускорение до 16x. Однако в моей реализации разницы между двумя партиционерами не наблюдается.

Вот мой тестовый код:

cpp
#include <oneapi/tbb/blocked_range.h>
#include <oneapi/tbb/parallel_for.h>
#include <oneapi/tbb/partitioner.h>

#include <chrono>
#include <print>
#include <vector>

using namespace oneapi;
using std::size_t;

template <typename Partitioner = tbb::auto_partitioner>
void parallel_compute(bool parallel, size_t size, unsigned steps, Partitioner partitioner = tbb::auto_partitioner{})
{
  std::vector<double> x(size, 1.0);
  std::vector<double> y(size);

  const auto t0{std::chrono::high_resolution_clock::now()};

  for (auto t{0u}; t < steps; ++t)
  {
    if (parallel)
    {
      tbb::parallel_for(
          tbb::blocked_range<size_t>{0, size},
          [&](const tbb::blocked_range<size_t>& range)
          {
            for (auto i{range.begin()}; i != range.end(); ++i) y[i] += x[i];
          },
          partitioner);
    }
    else
    {
      for (size_t i{0}; i != size; ++i) y[i] += x[i];
    }
  }

  const auto t1{std::chrono::high_resolution_clock::now()};
  const auto seconds{std::chrono::duration<double>(t1 - t0).count()};

  std::println("Result: {}, Size: {}, Time: {}s, Parallel: {}", y[0], size, seconds, parallel);
}

auto main() -> int
{
  while (true)
  {
    for (size_t size{1000}; size <= 100'000'000; size *= 10)
    {
      std::println("Running...");
      parallel_compute(true, size, 100, tbb::affinity_partitioner{});
      parallel_compute(true, size, 100, tbb::auto_partitioner{});
      parallel_compute(false, size, 100);
    }
  }
}

Какие модификации или альтернативные примеры я мог бы использовать, чтобы четко продемонстрировать преимущества производительности affinity_partitioner в TBB?

Основная причина, по которой вы не измеряете различий в производительности между tbb::affinity_partitioner и tbb::auto_partitioner, заключается в том, что ваш тест создает новый объект разделителя при каждом вызове, что лишает основного преимущества affinity_partitioner. Affinity_partitioner поддерживает состояние между параллельными операциями для оптимизации локальности кэша, но ваша реализация создает новый экземпляр каждый раз.

Содержание

Ключевые различия между разделителями

Фундаментальное различие заключается в том, как эти разделители управляют аффинностью потоков и локальностью кэша:

auto_partitioner: Автоматически определяет размер зерна и создает фрагменты работы на основе доступных ресурсов выполнения. Он предназначен для общей параллельности, но не поддерживает состояние между операциями для оптимизации локальности кэша.

affinity_partitioner: Как объясняется в документации Intel, он “не только автоматически выбирает размер зерна, но также оптимизирует для аффинности кэша и пытается равномерно распределить данные между потоками”. Что наиболее важно, “объект affinity_partitioner ap существует между итерациями цикла” и “запоминает, в каких потоках выполнялись итерации цикла, чтобы каждая итерация могла быть передана тому же потоку, который выполнял ее ранее” источник.

Это управление состоянием является ключевым - affinity_partitioner создает память о том, какие потоки обрабатывали какие области данных, позволяя последующим параллельным операциям повторно использовать те же назначения потоков при обработке тех же данных снова.

Почему ваш текущий тест не показывает различий

Ваша реализация не демонстрирует преимущества по следующим причинам:

  1. Создание нового разделителя: Вы создаете новый affinity_partitioner{} при каждом вызове функции, что prevents ему поддерживать состояние между итерациями.

  2. Одна операция: Каждый тест выполняет только 100 итераций одной и той же операции, но преимущества affinity_partitioner наиболее заметны при выполнении нескольких различных параллельных операций над одними и теми же данными.

  3. Простой шаблон доступа: Шаблон y[i] += x[i] слишком прост, чтобы показать преимущества локальности кэша.

  4. Размер данных: Размеры тестов (от 1000 до 100M элементов) могут быть не оптимальными для демонстрации эффектов аффинности кэша.

Модифицированные примеры тестов для демонстрации преимуществ

Пример 1: Повторно используемый разделитель с несколькими операциями

cpp
#include <oneapi/tbb/blocked_range.h>
#include <oneapi/tbb/parallel_for.h>
#include <oneapi/tbb/partitioner.h>
#include <chrono>
#include <print>
#include <vector>

using namespace oneapi;

void demonstrate_affinity_benefits(size_t size) {
    std::vector<double> x(size, 1.0);
    std::vector<double> y(size, 0.0);
    std::vector<double> z(size, 0.0);
    
    // Создаем разделители ОДИН РАЗ и повторно используем их
    tbb::affinity_partitioner affinity_partitioner;
    
    // Тест с auto_partitioner (новый экземпляр каждый раз)
    auto start_auto = std::chrono::high_resolution_clock::now();
    for (int iter = 0; iter < 10; ++iter) {
        tbb::parallel_for(
            tbb::blocked_range<size_t>{0, size},
            [&](const tbb::blocked_range<size_t>& range) {
                for (auto i = range.begin(); i != range.end(); ++i) {
                    y[i] = x[i] * 2.0;  // Первая операция
                    z[i] = y[i] + 1.0;  // Вторая операция над теми же данными
                }
            },
            tbb::auto_partitioner{}  // Новый экземпляр каждый раз
        );
    }
    auto end_auto = std::chrono::high_resolution_clock::now();
    
    // Тест с affinity_partitioner (повторно используемый экземпляр)
    auto start_aff = std::chrono::high_resolution_clock::now();
    for (int iter = 0; iter < 10; ++iter) {
        tbb::parallel_for(
            tbb::blocked_range<size_t>{0, size},
            [&](const tbb::blocked_range<size_t>& range) {
                for (auto i = range.begin(); i != range.end(); ++i) {
                    y[i] = x[i] * 2.0;  // Первая операция
                    z[i] = y[i] + 1.0;  // Вторая операция над теми же данными
                }
            },
            affinity_partitioner  // Повторно используемый экземпляр поддерживает состояние
        );
    }
    auto end_aff = std::chrono::high_resolution_clock::now();
    
    double auto_time = std::chrono::duration<double>(end_auto - start_auto).count();
    double aff_time = std::chrono::duration<double>(end_aff - start_aff).count();
    
    std::println("Размер: {}, Auto: {:.3f}s, Аффинность: {:.3f}s, Ускорение: {:.2f}x", 
                size, auto_time, aff_time, auto_time / aff_time);
}

Пример 2: Несколько проходов по одним и тем же данным

cpp
void demonstrate_multi_pass_benefits(size_t size) {
    std::vector<double> data(size, 1.0);
    tbb::affinity_partitioner affinity_partitioner;
    
    // Тест 1: Несколько отдельных операций с auto_partitioner
    auto start_auto = std::chrono::high_resolution_clock::now();
    
    // Первый проход
    tbb::parallel_for(tbb::blocked_range<size_t>{0, size},
        [&](const tbb::blocked_range<size_t>& range) {
            for (auto i = range.begin(); i != range.end(); ++i)
                data[i] *= 2.0;
        }, tbb::auto_partitioner{});
    
    // Второй проход
    tbb::parallel_for(tbb::blocked_range<size_t>{0, size},
        [&](const tbb::blocked_range<size_t>& range) {
            for (auto i = range.begin(); i != range.end(); ++i)
                data[i] += 1.0;
        }, tbb::auto_partitioner{});
    
    auto end_auto = std::chrono::high_resolution_clock::now();
    
    // Тест 2: Несколько операций с повторно используемым affinity_partitioner
    std::fill(data.begin(), data.end(), 1.0);
    auto start_aff = std::chrono::high_resolution_clock::now();
    
    // Первый проход - affinity_partitioner создает сопоставление потоков
    tbb::parallel_for(tbb::blocked_range<size_t>{0, size},
        [&](const tbb::blocked_range<size_t>& range) {
            for (auto i = range.begin(); i != range.end(); ++i)
                data[i] *= 2.0;
        }, affinity_partitioner);
    
    // Второй проход - те же потоки обрабатывают те же области для локальности кэша
    tbb::parallel_for(tbb::blocked_range<size_t>{0, size},
        [&](const tbb::blocked_range<size_t>& range) {
            for (auto i = range.begin(); i != range.end(); ++i)
                data[i] += 1.0;
        }, affinity_partitioner);
    
    auto end_aff = std::chrono::high_resolution_clock::now();
    
    double auto_time = std::chrono::duration<double>(end_auto - start_auto).count();
    double aff_time = std::chrono::duration<double>(end_aff - start_aff).count();
    
    std::println("Многоходовой - Размер: {}, Auto: {:.3f}s, Аффинность: {:.3f}s, Ускорение: {:.2f}x", 
                size, auto_time, aff_time, auto_time / aff_time);
}

Пример 3: Шаблон доступа к данным, дружественный к кэшу

cpp
void demonstrate_cache_friendly_pattern(size_t size) {
    // Создаем данные, которые выигрывают от пространственной локальности
    std::vector<std::array<double, 8>> data(size);
    for (auto& elem : data) elem.fill(1.0);
    
    tbb::affinity_partitioner affinity_partitioner;
    
    // Тест со страйдированным шаблоном доступа, который выигрывает от локальности кэша
    auto start_auto = std::chrono::high_resolution_clock::now();
    for (int iter = 0; iter < 20; ++iter) {
        tbb::parallel_for(tbb::blocked_range<size_t>{0, size},
            [&](const tbb::blocked_range<size_t>& range) {
                for (auto i = range.begin(); i != range.end(); ++i) {
                    // Обрабатываем несколько элементов за итерацию для локальности кэша
                    for (int j = 0; j < 8; ++j) {
                        data[i][j] *= 1.01;
                        data[i][j] += 0.001;
                    }
                }
            }, tbb::auto_partitioner{});
    }
    auto end_auto = std::chrono::high_resolution_clock::now();
    
    // Тест с affinity_partitioner
    for (auto& elem : data) elem.fill(1.0);
    auto start_aff = std::chrono::high_resolution_clock::now();
    for (int iter = 0; iter < 20; ++iter) {
        tbb::parallel_for(tbb::blocked_range<size_t>{0, size},
            [&](const tbb::blocked_range<size_t>& range) {
                for (auto i = range.begin(); i != range.end(); ++i) {
                    for (int j = 0; j < 8; ++j) {
                        data[i][j] *= 1.01;
                        data[i][j] += 0.001;
                    }
                }
            }, affinity_partitioner);
    }
    auto end_aff = std::chrono::high_resolution_clock::now();
    
    double auto_time = std::chrono::duration<double>(end_auto - start_auto).count();
    double aff_time = std::chrono::duration<double>(end_aff - start_aff).count();
    
    std::println("Дружественный к кэшу - Размер: {}, Auto: {:.3f}s, Аффинность: {:.3f}s, Ускорение: {:.2f}x", 
                size, auto_time, aff_time, auto_time / aff_time);
}

Оптимальные сценарии для affinity_partitioner

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

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

  2. Данные, помещающиеся в кэш: Как отмечено в исследованиях, “если цикл повторно выполняется над одним и тем же набором данных, который помещается в кэш, производительность может значительно улучшиться” источник.

  3. Ситуации с дисбалансом при перераспределении работы: Когда распределение работы может стать неравномерным, affinity_partitioner может обеспечить лучшую балансировку нагрузки, сохраняя при этом локальность кэша.

  4. Архитектуры NUMA: В системах с неравномерным доступом к памяти (NUMA) affinity_partitioner помогает поддерживать локальность данных для соответствующих узлов памяти.

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

Лучшие практики использования affinity_partitioner

  1. Повторно используйте объект разделителя: Всегда создавайте разделитель один раз и повторно используйте его в нескольких параллельных операциях.

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

  3. Используйте с заблокированными диапазонами: Комбинируйте с типами blocked_range для оптимальной производительности, как упоминается в исследованиях: “С алгоритмами циклов TBB мы использовали типы blocked_range affinity_partitioner и static_partitioner для настройки производительности кэша” источник.

  4. Учитывайте локальность данных: Проектируйте ваши алгоритмы с учетом пространственной и временной локальности при использовании affinity_partitioner.

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

Рекомендации по измерению производительности

При измерении различий в производительности:

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

  2. Множественные измерения: Запускайте тесты несколько раз и используйте статистические средние для получения надежных результатов.

  3. Эффекты кэша: Учитывайте, что эффекты кэша могут вызывать вариабельность в измерениях.

  4. Конкуренция системы: Запускайте тесты на простаивающих системах для минимизации внешних помех.

  5. Различные размеры задач: Тестируйте различные размеры данных, чтобы найти, где affinity_partitioner обеспечивает наибольшую выгоду.

Согласно документации Intel, affinity_partitioner “может значительно улучшить производительность при” работе с шаблонами повторного использования данных, и ключевой момент - обеспечение того, чтобы объект разделителя “существовал между итерациями цикла” для поддержания сопоставления аффинности потоков, которое обеспечивает эти преимущества производительности.

Источники

  1. Intel Threading Building Blocks - Bandwidth and Cache Affinity
  2. TBB Basics - Cache Locality Benefits
  3. Intel TBB Developer Guide - Partitioner Summary
  4. Tuning TBB Algorithms - Cache Performance
  5. Stack Overflow - affinity_partitioner Explanation

Заключение

Чтобы продемонстрировать измеримые различия в производительности между tbb::affinity_partitioner и tbb::auto_partitioner, вам необходимо:

  1. Повторно использовать объект разделителя в нескольких параллельных операциях - это критический фактор, который ваша текущая реализация упускает
  2. Разрабатывать тесты с временным повторным использованием данных, при которых одни и те же потоки обрабатывают одни и те же области данных несколько раз
  3. Использовать размеры задач, которые выигрывают от локальности кэша - как правило, наборы данных, помещающиеся в кэш или имеющие хорошую пространственную локальность
  4. Реализовывать несколько проходов по одним и тем же данным, чтобы affinity_partitioner мог создавать и повторно использовать сопоставления потоков
  5. Измерять производительность за достаточное количество итераций, чтобы зафиксировать совокупные преимущества оптимизации кэша

Ключевая идея из документации Intel заключается в том, что сила affinity_partitioner заключается в поддержании состояния между операциями для оптимизации локальности кэша, что работает только тогда, когда тот же экземпляр разделителя повторно используется в нескольких параллельных вызовах tbb::parallel_for или аналогичных функций.

Авторы
Проверено модерацией
Модерация