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

Первые ядра: от сложения чисел к векторам

О чём эта тема
Первая полная вычислительная программа на CUDA: сложение векторов со всеми копированиями, формула глобального индекса, защита от лишних тредов и вопрос, который решает всё, — какие циклы вообще можно раздавать тредам.
Аннотация
Конспект собирает воедино всё, что подготовили C01–C03. Сначала — нарочито игрушечное ядро, складывающее два числа: на нём виден каждый байт жизненного цикла данных. Затем задача честнее — сложить два вектора: сначала блоками по одному треду, потом тредами одного блока, и наконец — рабочей комбинацией «много блоков × много тредов», где появляется главная формула траектории: i = blockIdx.x · blockDim.x + threadIdx.x. Разбирается защита if (i < n) и вычисление числа блоков округлением вверх. Последний раздел — критерий, отделяющий распараллеливаемые циклы от нераспараллеливаемых: независимость итераций, паттерн map и антипример с рекуррентностью. Тренажёр гоняет по формуле индексации до автоматизма.
Пререквизиты
C02 — грид, блоки, threadIdx/blockIdx; C03 — cudaMalloc/cudaMemcpy/cudaFree.
Мотивация
До сих пор ядра только печатали. Но CUDA существует не для печати — для обработки массивов, у которых миллионы элементов и одна операция на всех. Сложение векторов — «Hello, World!» настоящего GPU-программирования: тот же скелет, что у него, вы потом узнаете в размытии картинок, физических симуляциях и нейросетевых слоях. Освоить его — значит уметь писать половину всех CUDA-программ; вторая половина начинается там, где треды перестают быть независимыми, и о ней — C05.

1. Разминка: сложить два числа

Задача абсурдно мала для видеокарты — тем и хороша: в программе не останется ничего, кроме протокола из C03, и каждый шаг видно под лупой.

#include <cstdio>

__global__ void add(int* a, int* b, int* c)
{
    *c = *a + *b;                 // один тред складывает два числа
}

int main()
{
    int a = 5, b = 10, c = 0;     // хост-копии
    int *da, *db, *dc;            // device-указатели
    int size = sizeof(int);

    cudaMalloc((void**)&da, size);            // 1. выделить на устройстве
    cudaMalloc((void**)&db, size);
    cudaMalloc((void**)&dc, size);

    cudaMemcpy(da, &a, size, cudaMemcpyHostToDevice);   // 2. вход туда
    cudaMemcpy(db, &b, size, cudaMemcpyHostToDevice);

    add<<<1, 1>>>(da, db, dc);                          // 3. посчитать

    cudaMemcpy(&c, dc, size, cudaMemcpyDeviceToHost);   // 4. результат обратно

    printf("%d + %d = %d\n", a, b, c);                 // 5 + 10 = 15
    cudaFree(da); cudaFree(db); cudaFree(dc);           // 5. освободить
    return 0;
}

Обратите внимание: между запуском ядра и cudaMemcpy нет cudaDeviceSynchronize, хотя запуск асинхронный. Это не ошибка: cudaMemcpy сам ждёт завершения всей предыдущей работы устройства — синхронизация встроена в него. Явная синхронизация нужна, когда после ядра идёт не копирование, а, например, печать с хоста (как в C01) или замер времени (C06).

Ядро выполнил один тред одного блока. Видеокарта с тысячами ядер простаивала — время её загрузить.

2. Сложение векторов: три конфигурации

Теперь складываем поэлементно два массива по n чисел. Работы n штук, и раздать её можно по-разному. У ДВФУ эти варианты идут как три отдельные программы; сведём их в таблицу — меняется только индекс и конфигурация запуска:

ЗапускИндекс в ядреКто работаетОграничение
<<<n, 1>>> i = blockIdx.x n блоков по одному треду блок из 1 треда — это варп, где занято 1 место из 32: 97% железа простаивает
<<<1, n>>> i = threadIdx.x один блок из n тредов в блоке максимум 1024 треда — вектор длиннее не обработать; занят один SM из десятков
<<<B, T>>> i = blockIdx.x·blockDim.x + threadIdx.x B блоков по T тредов рабочий вариант: масштабируется на любой n и любую карту

Формула третьей строки — глобальный индекс треда — самая используемая строчка в CUDA. Читается она так: пропустить все предыдущие блоки (blockIdx.x штук по blockDim.x тредов) и добавить свой номер в текущем блоке. Вы уже щёлкали по ней в тренажёре C02; теперь она встала на рабочее место:

__global__ void vecAdd(const float* a, const float* b, float* c, int n)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n)                    // защита: лишние треды не лезут за край массива
        c[i] = a[i] + b[i];
}

Откуда лишние треды? Из арифметики. Пусть n = 1000, а блок — 256 тредов. Блоков нужно 1000 / 256 = 3.9, а блоки бывают только целыми. Три блока дадут 768 тредов — мало; четыре — 1024, на 24 больше, чем элементов. Поэтому блоков берут с округлением вверх, а хвостовые треды отсекает то самое if (i < n):

const int block = 256;
int grid = (n + block - 1) / block;   // целочисленное «округление вверх»:
                                       // (1000 + 255) / 256 = 1255/256 = 4
vecAdd<<<grid, block>>>(da, db, dc, n);

Трюк (n + block − 1) / block стоит понять один раз навсегда: прибавка block − 1 «доталкивает» любой неполный остаток до следующего целого, а полные значения не трогает (при n = 1024: (1024 + 255)/256 = 4 — ровно). Полная программа вокруг ядра — тот же протокол из раздела 1, только size = n · sizeof(float):

#include <cstdio>

__global__ void vecAdd(const float* a, const float* b, float* c, int n)
{
    int i = blockIdx.x * blockDim.x + threadIdx.x;
    if (i < n) c[i] = a[i] + b[i];
}

int main()
{
    const int n = 1000;
    int size = n * sizeof(float);

    // хост: обычные массивы, заполняем тестовыми значениями
    float* a = new float[n];
    float* b = new float[n];
    float* c = new float[n];
    for (int i = 0; i < n; i++) { a[i] = i; b[i] = 2 * i; }

    // устройство: выделить и завезти вход
    float *da, *db, *dc;
    cudaMalloc((void**)&da, size);
    cudaMalloc((void**)&db, size);
    cudaMalloc((void**)&dc, size);
    cudaMemcpy(da, a, size, cudaMemcpyHostToDevice);
    cudaMemcpy(db, b, size, cudaMemcpyHostToDevice);

    // запуск: блоков с округлением вверх
    const int block = 256;
    int grid = (n + block - 1) / block;
    vecAdd<<<grid, block>>>(da, db, dc, n);

    // результат и проверка: c[i] должно равняться 3i
    cudaMemcpy(c, dc, size, cudaMemcpyDeviceToHost);
    bool ok = true;
    for (int i = 0; i < n; i++)
        if (c[i] != 3.0f * i) { printf("ошибка в %d: %f\n", i, c[i]); ok = false; break; }
    if (ok) printf("все %d элементов верны\n", n);

    cudaFree(da); cudaFree(db); cudaFree(dc);
    delete[] a; delete[] b; delete[] c;
    return 0;
}

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

Типичная ошибка Забыть защиту if (i < n). При n = 1000 и четырёх блоках по 256 тредов последние 24 треда честно выполнят c[i] = a[i] + b[i] для i от 1000 до 1023 — за границей массивов. Иногда это тихая порча соседней памяти, иногда падение ядра; и то и другое всплывает не сразу и не там, где посеяно. Правило: индекс из формулы — всегда под охраной сравнения с n.

3. Когда цикл можно раздать тредам

Сложение векторов легло на GPU без сопротивления. Почему? Взгляните на его последовательную версию:

for (int i = 0; i < n; i++)
    c[i] = a[i] + b[i];          // итерация i не трогает ничьих чужих данных

Каждая итерация читает только a[i] и b[i], пишет только в c[i] — и ей безразлично, выполнены ли остальные итерации, идут ли они сейчас или ещё не начинались. Это критерий независимости итераций, и он же — критерий «цикл можно раздать тредам»: строка тела цикла превращается в ядро, счётчик i — в глобальный индекс, и порядок выполнения перестаёт иметь значение (а C02 научил: порядка и не будет). Такой паттерн называют map: одна независимая операция на элемент.

Теперь антипример — накопленная сумма:

for (int i = 1; i < n; i++)
    x[i] = x[i - 1] + a[i];     // итерация i ЖДЁТ результат итерации i-1

Итерация i читает x[i−1] — то, что записала предыдущая итерация. Раздать этот цикл тредам «в лоб» нельзя: тред i запустится когда угодно и почти наверняка прочитает x[i−1] до того, как тред i−1 туда что-то запишет. Получится мусор — причём каждый запуск разный. Это рекуррентность, перенос зависимости между итерациями.

Между чистым map и безнадёжной рекуррентностью лежат два важных промежуточных паттерна. Reduce — свёртка массива в одно число (сумма, максимум): итерации зависят через общий аккумулятор, но операция ассоциативна, и зависимость можно перестроить в дерево частичных сумм — этим займётся C05. Scan — префиксные суммы: выглядит как рекуррентность, но тоже параллелится специальным алгоритмом. Полная классификация с примерами на Python и разбором того, что умеет и чего не умеет автоматический параллелизатор, — в конспекте P02 соседней линии; для линии C достаточно правила:

map
независимые итерации
→ тред на элемент (этот конспект)
reduce / scan
зависимость перестраивается
→ спец-алгоритмы (C05)
рекуррентность
честная цепочка
Типичная ошибка Убедиться, что «результат совпал», на одном запуске распараллеленной рекуррентности. Гонка — ошибка вероятностная: на маленьком n все треды могут случайно успеть в удачном порядке, и тест позеленеет. Проверяйте зависимость итераций по коду, а не по везению: если тело цикла читает то, что пишет другая итерация, — это не map, сколько бы раз тест ни сошёлся.

4. Тренажёр: индексация до автоматизма

Тренажёр · какой тред обработает элемент k

Дана конфигурация запуска и номер элемента. Определите, какой тред его обработает — или что элемент вообще никому не достался. Формула: i = blockIdx.x · blockDim.x + threadIdx.x.

Серия не начата.

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

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

Источники

  1. An Even Easier Introduction to CUDA // NVIDIA Developer Blog : [сайт]. — URL: https://developer.nvidia.com/blog/even-easier-introduction-cuda/ (дата обращения: 09.07.2026).
  2. CUDA C++ Programming Guide. Kernels; Thread Hierarchy // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cuda-c-programming-guide/ (дата обращения: 09.07.2026).