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

Перемножение матриц: наивное и тайловое

О чём эта тема
Классика GPU-вычислений: произведение матриц сначала «в лоб» — по треду на элемент результата, — а затем тайлами через разделяемую память, где каждый прочитанный из DRAM байт работает не один раз, а шестнадцать.
Аннотация
Двумерная задача заслуживает двумерного грида: конспект начинается с раскладки матрицы по блокам 16×16 и формулы двумерной индексации. Наивное ядро — по треду на элемент C — работает, но каждый тред честно читает из глобальной памяти целую строку A и столбец B: на матрице 1024×1024 это 2N чтений DRAM на элемент, и калькулятор из C06 показал бы жалкие проценты пика. Дальше — приём, ради которого написан конспект: разбить вычисление на фазы по тайлам, каждый тайл загружать в разделяемую память силами всего блока и переиспользовать всеми тредами блока. Выигрыш по чтениям — ровно в размер тайла; вывод формулы, полный код с двумя __syncthreads и разбор, зачем каждый. Тренажёр — интерактивная карта тайлов: щёлкните блок матрицы C и прокрутите фазы загрузки.
Пререквизиты
C02 — dim3 и двумерные гриды; C05 — __shared__, __syncthreads и правило «барьер вне if».
Мотивация
Матричное умножение — рабочая лошадь всего вычислительного мира: слои нейросетей, системы уравнений, графика — всюду оно. Поэтому же это любимый пример оптимизации: между наивным ядром и библиотечным cuBLAS лежит десятикратная пропасть, и первый, самый большой её кусок закрывается одним приёмом — тайлингом. Это тот же трюк «переиспользуй быструю память», что и в C05, но теперь он даёт не изящество, а кратный прирост скорости, измеримый секундомером из C06.

1. Двумерная задача — двумерный грид

Произведение C = A·B квадратных матриц N×N: элемент Crow,col — скалярное произведение строки row матрицы A и столбца col матрицы B (по N умножений и сложений на элемент, всего 2N³ операций). Элементов N² — и мы уже знаем, что делать с большим числом одинаковых независимых вычислений: по треду на элемент. Только теперь у элемента два индекса, и грид удобно сделать двумерным (dim3 из C02):

#define N 1024          // размер матрицы
#define TILE 16          // размер блока 16×16 = 256 тредов

dim3 block(TILE, TILE);
dim3 grid(N / TILE, N / TILE);        // 64×64 блока; N кратно TILE для простоты
matMul<<<grid, block>>>(A, B, C, N);

Индексация — та же формула C04, применённая дважды, по оси на измерение:

int row = blockIdx.y * blockDim.y + threadIdx.y;   // строка элемента C
int col = blockIdx.x * blockDim.x + threadIdx.x;   // столбец элемента C

Матрица хранится в памяти одномерно, построчно: элемент (row, col) лежит по смещению row * N + col. Наивное ядро:

__global__ void matMulNaive(const float* A, const float* B, float* C, int n)
{
    int row = blockIdx.y * blockDim.y + threadIdx.y;
    int col = blockIdx.x * blockDim.x + threadIdx.x;

    float sum = 0.0f;
    for (int k = 0; k < n; k++)
        sum += A[row * n + k] * B[k * n + col];   // строка A · столбец B

    C[row * n + col] = sum;
}

Ядро верное и уже быстрее CPU. Но посчитаем байты, как учил C06: каждый тред читает N элементов строки A и N элементов столбца B — 2N чтений глобальной памяти ради 2N операций сложения-умножения. Одна арифметическая операция на каждое чтение DRAM — для задачи, где данных N² и работы N³, это расточительность: соседние треды читают одни и те же строки и столбцы, каждый сам по себе.

2. Тайлы: один раз прочитал — шестнадцать раз использовал

Присмотримся к блоку 16×16 тредов, вычисляющему квадрат 16×16 матрицы C. Всем его тредам нужны одни и те же 16 строк A и 16 столбцов B. Идея тайлинга: пусть блок читает эти данные из глобальной памяти совместно и по одному разу — в разделяемую память (C05), а уже из неё каждый тред берёт свои сомножители сколько угодно раз.

Целиком 16 строк в разделяемую память не влезут (16 · 1024 float = 64 КБ — больше лимита блока), поэтому работа нарезается на фазы. В фазе p блок загружает два тайла 16×16: кусок строк A с колонками [16p … 16p+15] и кусок столбцов B со строками [16p … 16p+15], — перемножает их и накапливает вклад в сумму. Фаз всего N / 16, и после последней в аккумуляторе каждого треда — готовый элемент C:

A полоса строк блока · тайл фазы p=1 B полоса столбцов · тайл фазы p=1 C = A·B блок (bx=1, by=2): свой квадрат 16×16 фаза p: тайл A(by, p) и тайл B(p, bx) → разделяемая память → 16 умножений на тред фаз N/16; тайлы сдвигаются по полосе A вправо и по полосе B вниз синхронно
__global__ void matMulTiled(const float* A, const float* B, float* C, int n)
{
    __shared__ float tA[TILE][TILE];   // тайл полосы A
    __shared__ float tB[TILE][TILE];   // тайл полосы B

    int tx = threadIdx.x, ty = threadIdx.y;
    int row = blockIdx.y * TILE + ty;
    int col = blockIdx.x * TILE + tx;

    float sum = 0.0f;

    for (int p = 0; p < n / TILE; p++) {
        // каждый тред приносит по одному элементу каждого тайла:
        // 256 тредов загружают 2·256 значений — совместно и без дублей
        tA[ty][tx] = A[row * n + (p * TILE + tx)];
        tB[ty][tx] = B[(p * TILE + ty) * n + col];

        __syncthreads();               // тайлы загружены целиком — можно считать

        for (int k = 0; k < TILE; k++)
            sum += tA[ty][k] * tB[k][tx];   // 16 умножений по быстрой памяти

        __syncthreads();               // досчитали — только теперь можно грузить следующий
    }

    C[row * n + col] = sum;
}

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

Теперь арифметика выигрыша. Каждый элемент A теперь читается из глобальной памяти не 16 тредами по разу каждый, а один раз на блок — дальше он крутится в разделяемой памяти. Чтений глобальной памяти на тред: было 2N, стало 2N / TILE — в 16 раз меньше. Общее правило: тайл T×T сокращает трафик DRAM в T раз; на практике ядро ускоряется в 3–6 раз (не в 16 — потому что наивной версии частично помогали кэши, а тайловой добавились барьеры). Проверьте на своей карте секундомером из C06 — числа скажут сами.

Типичная ошибка Увеличить TILE «для большего выигрыша» до 32 и получить ядро, которое не запускается: блок 32×32 — это 1024 треда, ровно лимит (уже на грани), а два тайла float 32×32 — 8 КБ разделяемой памяти на блок, что сокращает число блоков на SM и может ударить по загрузке. TILE = 16 — сбалансированная классика; менять его стоит только с секундомером в руках и cudaGetLastError после запуска.

3. Тренажёр: карта тайлов

Тренажёр · какие тайлы читает блок

Матрицы 4×4 тайла (N = 64, TILE = 16). Щёлкните блок в матрице C — подсветятся его полоса строк в A и полоса столбцов в B. Кнопка «фаза» прокручивает загрузку тайлов в разделяемую память; счётчик внизу сравнивает чтения глобальной памяти с наивным ядром.

A (тайлы)
B (тайлы)
C — щёлкните блок
Выберите блок матрицы C.

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

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

Источники

  1. CUDA C++ Programming Guide. Shared Memory: Matrix Multiplication Example // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cuda-c-programming-guide/ (дата обращения: 09.07.2026).
  2. CUDA C++ Best Practices Guide. Shared Memory in Matrix Multiplication // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/ (дата обращения: 09.07.2026).