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

Программная модель CUDA: грид, блоки, треды

О чём эта тема
Как CUDA видит видеокарту: иерархия «грид → блоки → треды», варпы по 32, спецификаторы функций, синтаксис запуска ядра и встроенные переменные, по которым каждый тред узнаёт, кто он такой.
Аннотация
Конспект начинается с вопроса, почему видеокарта вообще быстрее процессора в счётных задачах — и в чём она безнадёжно медленнее. Затем вводится словарь CUDA: хост и устройство, ядро, грид, блок, тред, варп, потоковый мультипроцессор — с одной сквозной аналогией и собственной схемой иерархии. Разбираются спецификаторы __global__, __device__ и __host__, синтаксис запуска kernel<<<блоки, треды>>> и встроенные переменные threadIdx, blockIdx, blockDim, gridDim вместе с типом dim3 для двумерных и трёхмерных конфигураций. Работающий пример печатает приветствия из шести тредов двух блоков и показывает, что порядок выполнения не гарантирован. В тренажёре грид собирается ползунками: щёлкните по треду — и увидите его координаты, глобальный номер и варп.
Пререквизиты
C01 — рабочее место настроено, «Hello, CUDA!» компилируется и запускается.
Мотивация
В «Hello, CUDA!» из C01 одна функция напечатала четыре строки — по числу тредов. Уже там в коде стояли загадочные тройные скобки и переменная threadIdx, взявшаяся ниоткуда. Дальше так продолжаться не может: вся практика CUDA — это распределение работы между тысячами тредов, и без точного понимания, как они организованы и пронумерованы, не написать даже сложение векторов. Этот конспект — карта местности, по которой будут ходить все остальные.

1. Почему видеокарта считает быстрее — и когда нет

Центральный процессор спроектирован, чтобы выполнить одну цепочку инструкций как можно быстрее: большие кэши, предсказание ветвлений, высокая тактовая частота. Ядер у него немного — единицы или десятки. Видеокарта устроена наоборот: тысячи простых ядер без хитрой логики. Каждое из них медленнее ядра CPU, но их очень много.

Отсюда правило, которое стоит запомнить до всякого кода: CPU выигрывает по латентности (время одной операции), GPU — по пропускной способности (операций в секунду на всём устройстве). Если задача — миллион одинаковых независимых вычислений, GPU разложит их по своим ядрам и обгонит процессор на порядок. Если задача — длинная цепочка, где каждый шаг ждёт предыдущий, тысячи ядер будут простаивать, и CPU окажется быстрее. Какие алгоритмы «раскладываются», а какие нет — отдельная большая тема (C04 и конспект P02 соседней линии); пока договоримся о словах.

Модель выполнения GPU называется SIMT (Single Instruction, Multiple Threads): одна инструкция, много тредов. Группа тредов шагает по программе строем — все выполняют одну и ту же строку кода, но каждый над своими данными.

2. Словарь CUDA: одна аналогия на восемь терминов

Представьте завод, получивший заказ. Заказ — это ядро: одна инструкция «что делать». Под заказ собирают всех исполнителей — это грид. Исполнители организованы в бригады-блоки: у бригады общий склад инструментов (общая память) и планёрки (синхронизация); бригада целиком работает в одном цехе-мультипроцессоре. Внутри бригады рабочие-треды ходят шеренгами по 32 человека — варпами — и шагают строго в ногу: одна команда на шеренгу. Теперь строго:

ТерминЧто этоВ аналогии
хост (host) Центральный процессор, управляющий программой: готовит данные, запускает ядра. заказчик
устройство (device) Видеокарта — сопроцессор, выполняющий параллельную часть работы. завод
ядро (kernel) Функция, которую выполняет каждый тред грида; параллельная часть алгоритма. заказ-инструкция
грид (grid) Все треды одного запуска ядра, организованные в блоки. все исполнители заказа
блок (block) Группа тредов с общей быстрой памятью и возможностью синхронизации; выполняется целиком на одном мультипроцессоре. Имеет номер внутри грида. бригада
тред (thread) Единица выполнения: один экземпляр ядра над своей порцией данных. Имеет номер внутри блока. рабочий
варп (warp) 32 подряд идущих треда блока; физически выполняются одновременно, одной инструкцией. шеренга в ногу
SM Потоковый мультипроцессор (Streaming Multiprocessor): узел из десятков ядер CUDA с общей памятью; его планировщик перебирает варпы — пока одни ждут данные из памяти, другие считают, и простои прячутся. цех с диспетчером
грид — один запуск ядра kernel<<<3, 64>>>() → 3 блока по 64 треда блок 0 варп 0 · треды 0–31 варп 1 · треды 32–63 блок 1 варп 0 варп 1 блок 2 варп 0 варп 1 SM №k блок целиком живёт на одном SM SM №m разные блоки могут попасть на разные SM

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

3. Спецификаторы: кто вызывает и где выполняется

В .cu-файле живут функции трёх сортов, и различаются они префиксом-спецификатором:

СпецификаторВыполняется наВызывается сТипичная роль
__global__GPUCPU ядро — точка входа в параллельный код; обязана возвращать void
__device__GPUGPU вспомогательная функция, которую зовут из ядра
__host__CPUCPU обычная функция C++ (спецификатор можно опускать)

Полезная комбинация __host__ __device__ помечает функцию, которая компилируется дважды — и для CPU, и для GPU: удобно для маленькой математики вроде clamp или преобразования координат, чтобы не писать её два раза.

// вспомогательная функция устройства: вызывается из ядра
__device__ float squared(float x)
{
    return x * x;
}

// ядро: вызов с хоста, исполнение на устройстве, возвращает всегда void
__global__ void kernel(float* data)
{
    data[threadIdx.x] = squared(data[threadIdx.x]);
}
Типичная ошибка Попытаться вернуть результат из ядра: __global__ float kernel() не скомпилируется — ядро возвращает только void. Причина в асинхронности: хост запускает ядро и бежит дальше, «возвращать» значение просто некому и некогда. Результаты ядро записывает в память устройства, а хост потом копирует их себе — механика этого копирования и есть тема C03.

4. Запуск ядра и встроенные переменные

Тройные угловые скобки из C01 — это конфигурация грида:

kernel<<< 3, 64 >>>(аргументы);   // 3 блока × 64 треда = 192 треда

Первое число — сколько блоков в гриде, второе — сколько тредов в каждом блоке. (У конструкции есть ещё два необязательных параметра — размер динамической разделяемой памяти и поток выполнения; они понадобятся в C05 и позже.) Внутри ядра каждый тред ориентируется по четырём встроенным переменным:

ПеременнаяСмыслДля запуска <<<3, 64>>>
threadIdxномер треда внутри блока.x от 0 до 63
blockIdxномер блока внутри грида.x от 0 до 2
blockDimразмер блока (тредов в блоке).x = 64
gridDimразмер грида (блоков в гриде).x = 3

У всех четырёх есть поля .x, .y, .z: грид и блоки могут быть одномерными, двумерными и трёхмерными. Для картинок и матриц удобна двумерная конфигурация — она задаётся типом dim3 (незаполненные компоненты становятся единицами):

dim3 block(16, 16);                // блок 16×16 = 256 тредов
dim3 grid (32, 32);                // грид 32×32 блока — хватит на картинку 512×512
kernel<<< grid, block >>>(…);     // внутри ядра работают threadIdx.y, blockIdx.y

Соберём всё в работающий пример — «перекличку» тредов:

#include <cstdio>

__global__ void rollcall()
{
    printf("блок %d, тред %d\n", blockIdx.x, threadIdx.x);
}

int main()
{
    rollcall<<<2, 3>>>();        // 2 блока по 3 треда
    cudaDeviceSynchronize();      // дождаться видеокарту (C01)
    return 0;
}
> nvcc rollcall.cu -o rollcall && ./rollcall
блок 1, тред 0
блок 1, тред 1
блок 1, тред 2
блок 0, тред 0
блок 0, тред 1
блок 0, тред 2

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

Типичная ошибка Заложиться на порядок выполнения: «блок 0 закончит раньше блока 1» или «тред 5 успеет записать значение до того, как его прочитает тред 100». Никакого порядка между блоками CUDA не обещает — программа, которая случайно работает на одной карте, молча сломается на другой. Правило простое: каждый тред пишет в свои ячейки, а если тредам нужно договариваться — для этого есть явные механизмы (__syncthreads внутри блока, C05).

5. Тренажёр: грид на ладони

Тренажёр · грид, блоки, треды и варпы

Соберите конфигурацию запуска ползунками и щёлкните по любому треду: тренажёр покажет его координаты, глобальный номер и подсветит весь его варп. Обратите внимание, что происходит с последним варпом, когда размер блока не кратен 32.

Щёлкните по треду.

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

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

Источники

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