Траектория «Параллельные вычисления» · линия C · конспект 5 из 8

Общение тредов: скалярное произведение и редукция

О чём эта тема
Первая задача, где треды обязаны сотрудничать: скалярное произведение. Разделяемая память как место встречи, __syncthreads как планёрка и древовидная редукция — приём, который параллелит «непараллелящуюся» сумму.
Аннотация
В C04 остался открытым вопрос: как раздать тредам цикл, итерации которого зависят друг от друга через общий аккумулятор? Скалярное произведение — точно такой случай: умножения независимы, но потом их надо сложить в одно число. Конспект проходит три версии решения. Наивная — каждый тред умножает, один складывает — работает, но последний шаг последовательный. Древовидная — за log₂(n) шагов половина оставшихся тредов складывает пары — превращает 512 сложений в 9 параллельных шагов. Межблочная — atomicAdd собирает частичные суммы блоков без второго запуска ядра. По дороге — __shared__ на практике, строгие правила __syncthreads и классическая ошибка «синхронизация внутри if». Тренажёр прокручивает дерево редукции по шагам на живых числах.
Пререквизиты
C03 — разделяемая память и банки; C04 — глобальный индекс, паттерны map и reduce.
Мотивация
Map-задачи из C04 — каждый тред обрабатывает свой элемент — покрывают половину практики CUDA. Вторая половина начинается с вопроса «а теперь сложите всё вместе»: суммы, максимумы, средние, нормы векторов, ошибки нейросетей. Всё это редукции, и все они упираются в одно и то же: тысячи тредов должны договориться об одном числе. Приём, который вы освоите на скалярном произведении, дальше будет включаться везде — вплоть до тайлового перемножения матриц в C07.

1. Задача: перемножили — а сложить кто будет?

Скалярное произведение двух векторов — сумма попарных произведений: a·b = a₀b₀ + a₁b₁ + … + aₙ₋₁bₙ₋₁. Первая половина задачи — чистый map из C04: тред i умножает a[i] на b[i], итерации независимы. А вот сумма — паттерн reduce: результат зависит от всех элементов сразу. Наивная попытка «пусть каждый прибавит к общей переменной» — это гонка данных: треды будут одновременно читать-и-перезаписывать одно место, теряя чужие слагаемые (в P02 соседней линии эта авария воспроизводится вживую на Python).

Первое честное решение: копят все, складывает один. Каждый тред кладёт своё произведение в разделяемую память — «на общий стол» блока, — все дожидаются друг друга, и тред 0 проходит по столу циклом:

#define N 512

__global__ void dot(const float* a, const float* b, float* result)
{
    __shared__ float temp[N];              // общий стол блока (C03)
    int i = threadIdx.x;

    temp[i] = a[i] * b[i];                 // map-часть: каждый — своё произведение

    __syncthreads();                       // планёрка: ждём, пока стол заполнится

    if (i == 0) {                          // сложение — в одни руки
        float sum = 0;
        for (int k = 0; k < N; k++)
            sum += temp[k];
        *result = sum;
    }
}

Без __syncthreads() программа неверна: варпы блока идут не в ногу (C02), и тред 0 мог бы начать суммировать стол, на который варп 7 ещё ничего не положил. Синхронизация барьером даёт гарантию: ни один тред не пройдёт дальше этой строки, пока до неё не дошли все треды блока.

Решение работает — и разбазаривает параллелизм: пока тред 0 крутит цикл из 512 сложений, 511 тредов смотрят. Время работы по-прежнему O(n), как на CPU.

2. Дерево: сумма за логарифм

Сложение ассоциативно: слагаемые можно группировать как угодно. Сгруппируем так, чтобы работа делилась пополам. Шаг 1: каждый тред из первой половины прибавляет к своей ячейке ячейку из второй половины — 256 сложений одновременно. Шаг 2: то же с половиной оставшегося — 128 сложений. И так далее: 512 → 256 → 128 → … → 1 за log₂ 512 = 9 шагов вместо 512.

__global__ void dot_tree(const float* a, const float* b, float* result)
{
    __shared__ float temp[N];
    int i = threadIdx.x;

    temp[i] = a[i] * b[i];
    __syncthreads();

    // дерево: на каждом шаге работает первая половина оставшихся тредов
    for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
        if (i < stride)
            temp[i] += temp[i + stride];
        __syncthreads();               // барьер ПОСЛЕ каждого шага — вне if!
    }

    if (i == 0) *result = temp[0];     // ответ скопился в нулевой ячейке
}

Присмотритесь к позиции барьера: __syncthreads() стоит после if, на пути каждого треда. Так задумано, и это не мелочь:

Типичная ошибка Занести __syncthreads() внутрь if (i < stride) { … }. Барьер ждёт все треды блока, а внутрь ветки заходит только половина: вторая половина к барьеру не придёт никогда, и блок зависнет (или, что хуже, поведение окажется неопределённым и «почти работающим»). Железное правило: __syncthreads() выполняется либо всеми тредами блока, либо никем — никаких барьеров в расходящихся ветках.

Почему деление именно «первая половина + вторая половина», а не «соседние пары» (temp[2i] += temp[2i+1])? Оба варианта математически верны, но соседние пары на каждом шаге оставляют работающие треды через одного — варп из 32 тредов, где активен каждый второй, всё равно занимает такт целиком (SIMT, C02). Схема со stride держит работающие треды плотной пачкой в младших варпах: остальные варпы отключаются целиком и не тратят тактов. Плюс, как бонус, подряд идущие треды читают подряд идущие ячейки — привет банкам из C03: конфликтов нет.

3. Больше одного блока: atomicAdd

Пока вектор помещался в один блок (N ≤ 1024), хватало разделяемой памяти. Настоящие векторы длиннее, значит, блоков много, а __syncthreads между блоками не существует — блоки принципиально независимы (C02). Схема такая: каждый блок деревом сводит свой кусок к одной частичной сумме, а частичные суммы блоков собираются в глобальной памяти атомарным сложением:

__global__ void dot_big(const float* a, const float* b, float* result, int n)
{
    __shared__ float temp[256];
    int tid = threadIdx.x;
    int i = blockIdx.x * blockDim.x + threadIdx.x;    // глобальный индекс (C04)

    temp[tid] = (i < n) ? a[i] * b[i] : 0.0f;         // хвостовые треды кладут ноль
    __syncthreads();

    for (int stride = blockDim.x / 2; stride > 0; stride /= 2) {
        if (tid < stride) temp[tid] += temp[tid + stride];
        __syncthreads();
    }

    if (tid == 0)
        atomicAdd(result, temp[0]);    // частичную сумму блока — в общий результат
}

atomicAdd(адрес, x) — «прочитать, прибавить, записать» как одна неделимая операция: два блока, добравшиеся до result одновременно, выстроятся в очередь, и ни одно слагаемое не потеряется. Это ровно то, чего не хватало наивному «пусть каждый прибавит к общей переменной» — но заметьте цену: атомарные операции сериализуются. Здесь их всего по одной на блок (десятки штук) — дёшево. Делать atomicAdd каждым тредом — значит выстроить в очередь миллионы операций и убить параллелизм; дерево в разделяемой памяти существует именно затем, чтобы атомарной осталась одна капля на блок. Перед запуском ядра не забудьте обнулить result (cudaMemset(result, 0, sizeof(float))).

Замечание для дотошных: сложение float неассоциативно на уровне округлений, поэтому редукция и последовательная сумма могут расходиться в последних битах — и порядок блоков от запуска к запуску меняет эти биты. Для проверок сравнивайте с допуском (fabs(x − y) < 1e-5 · масштаб), а не оператором ==.

4. Тренажёр: дерево на живых числах

Тренажёр · древовидная редукция по шагам

16 тредов уже положили свои произведения в разделяемую память. Нажимайте «шаг»: активные треды (подсвечены) складывают свою ячейку с ячейкой на stride правее; погасшие ячейки уже сыграли и выбывают. Сумма собирается в ячейке 0 за log₂ 16 = 4 шага.

Контрольные вопросы

Благодарность Линия C этой траектории опирается на курс «Программирование CUDA» Лаборатории суперкомпьютерных и квантовых вычислений ДВФУ — https://cc.dvfu.ru/.

Источники

  1. Optimizing Parallel Reduction in CUDA / M. Harris // NVIDIA Developer Technology : [презентация]. — URL: https://developer.download.nvidia.com/assets/cuda/files/reduction.pdf (дата обращения: 09.07.2026).
  2. CUDA C++ Programming Guide. Synchronization Functions; Atomic Functions // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cuda-c-programming-guide/ (дата обращения: 09.07.2026).
  3. Using Shared Memory in CUDA C/C++ // NVIDIA Developer Blog : [сайт]. — URL: https://developer.nvidia.com/blog/using-shared-memory-cuda-cc/ (дата обращения: 09.07.2026).