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

Разреженные матрицы: форматы хранения и SpMV

О чём эта тема
Матрицы, в которых почти все элементы — нули: как их хранить, не тратя память на пустоту (COO, CSR), как умножать на вектор на CPU и GPU и почему разреженное умножение — отдельная дисциплина со своими ловушками.
Аннотация
Большие матрицы реальных задач почти всегда разрежены: уравнение теплопроводности на сетке даёт не больше пяти ненулевых на строку, матрица дружбы соцсети — сотни из миллиардов. Конспект начинается с картин разреженности и арифметики масштаба: измеренный пример — матрица 10 000×10 000 при 0.1% заполнения занимает 800 МБ в плотном виде и 1.2 МБ в формате CSR, а умножается на вектор в 215 раз быстрее. Дальше — сами форматы: координатный COO и построчный CSR, разобранный на матрице 4×4 до каждого числа в трёх массивах (сверено со scipy). Центральная часть — SpMV, умножение разреженной матрицы на вектор: ядро CUDA «строка на тред», его болезнь — дисбаланс нагрузки при рваных строках — и лечение «варп на строку»; в продакшене всё это уже написано в библиотеке cuSPARSE. Python-часть — scipy.sparse с измеренными числами и классической ловушкой поэлементной вставки в CSR. Тренажёр — редактор: рисуете матрицу мышью, массивы CSR и счётчик памяти перестраиваются на лету.
Пререквизиты
C07 — плотное умножение и двумерный грид; C06 — пропускная способность (SpMV — задача о байтах); C02 — варпы (для раздела о дисбалансе).
Мотивация
Тайловое умножение из C07 прекрасно, пока матрица плотная. Но матрицы из физики, графов и рекомендательных систем на 99% состоят из нулей — и «прекрасное» ядро будет на 99% умножать нули, предварительно не поместившись в память. Разреженные форматы — это переход от «таблицы всех клеток» к «списку того, что есть»; он экономит память на порядки, но взамен ломает регулярность доступа, на которой держались все приёмы линии C. Как параллелить нерегулярное — последний важный урок траектории.

1. Матрицы, состоящие из пустоты

Матрица называется разреженной (sparse), когда ненулевых элементов в ней настолько мало, что это выгодно использовать, — на практике доли процента. Это не экзотика, а нормальное состояние больших матриц реального мира:

Три картины разреженности матриц 576 на 576: пятидиагональная от сетки теплопроводности, трёхдиагональная с выбросами, случайный граф связей
Картины разреженности трёх матриц 576×576: точка — ненулевой элемент (построено scipy.sparse.spy; скрипт — в папке сode)

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

Картина разреженности матрицы метода конечных элементов: плотная диагональ и нерегулярная россыпь внедиагональных элементов
Матрица метода конечных элементов (источник: Wikimedia Commons, File:Finite element sparse matrix.png)

Теперь арифметика масштаба — числа измерены, скрипт в папке сode. Матрица 10 000 × 10 000 double при 0.1% ненулевых (сто тысяч чисел из ста миллионов клеток):

ХранениеПамятьУмножение на вектор
плотный массив800 МБ16.4 мс
CSR1.2 МБ0.076 мс
выигрыш×645×215

Выигрыш по времени — прямое следствие выигрыша по памяти: умножение на вектор упирается в пропускную способность памяти (урок C06), а разреженный формат просто не читает нули. Причём 800 МБ — это ещё скромно: матрица смежности соцсети в плотном виде не поместилась бы ни в одну машину мира.

2. Форматы: COO и CSR на пальцах

Идея всех форматов одна: хранить список ненулевых, а не таблицу всех клеток. Разберём два главных на одной матрице:

        столбцы:  0  1  2  3
              ┌               ┐
    строка 0  │  5  0  0  1   │
    строка 1  │  0  8  0  0   │
    строка 2  │  0  0  3  0   │
    строка 3  │  0  6  0  4   │
              └               ┘        ненулевых: 6 из 16

COO (coordinate) — самый наивный: три массива длины nnz («number of nonzeros»), по тройке (строка, столбец, значение) на элемент:

row = [0, 0, 1, 2, 3, 3]
col = [0, 3, 1, 2, 1, 3]
val = [5, 1, 8, 3, 6, 4]

COO удобен для сборки матрицы (дописывай тройки в любом порядке), но плох для вычислений: элементы одной строки нужно ещё найти. CSR (compressed sparse row) чинит это, сортируя элементы по строкам и сжимая массив row: вместо номера строки у каждого элемента храним, где в data начинается каждая строка:

data    = [5, 1, 8, 3, 6, 4]      # значения, строка за строкой
indices = [0, 3, 1, 2, 1, 3]      # столбец каждого значения
indptr  = [0, 2, 3, 4, 6]         # границы строк: строка r живёт
                                  # в data[indptr[r] : indptr[r+1]]

Проверьте себя по indptr: строка 0 — элементы data[0:2] = {5, 1} (столбцы 0 и 3); строка 1 — data[2:3] = {8}; строка 2 — data[3:4] = {3}; строка 3 — data[4:6] = {6, 4}. Длина indptr — всегда «строк + 1», а последний элемент равен nnz. Эти массивы сверены со scipy.sparse.csr_matrix — она строит в точности их.

ФорматЧто хранитПамятьКогда хорош
COOтройки (row, col, val)3·nnz сборка матрицы, обмен данными; порядок любой
CSRdata, indices, indptr2·nnz + n + 1 вычисления по строкам: SpMV, решатели — формат по умолчанию
CSCто же, но по столбцам2·nnz + n + 1 доступ по столбцам, транспонированные задачи
ELLпрямоугольник nnz-на-строку с паддингом2·n·max_nnz GPU при почти одинаковой длине строк (сетки); рваные строки съедают выгоду
Типичная ошибка Собирать матрицу поэлементной вставкой прямо в CSR: A[i, j] = v в цикле. Вставка в середину сжатого формата — это сдвиг хвостов всех трёх массивов; scipy честно предупреждает (SparseEfficiencyWarning), а время сборки становится квадратичным. Правильно: накопить тройки в COO (или LIL), собрать целиком и один раз конвертировать: coo_matrix((val, (row, col))).tocsr().

3. SpMV: умножение на вектор и его дисбаланс

SpMV (sparse matrix–vector multiplication, y = A·x) — рабочая лошадь разреженного мира: итерационные решатели, PageRank, графовые нейросети внутри крутят именно её. По CSR она пишется в четыре строки — и заметьте, насколько это близко к скалярному произведению из C05, только «своя» часть данных у каждой строки своя:

for (int row = 0; row < n; row++) {
    float sum = 0.0f;
    for (int k = indptr[row]; k < indptr[row + 1]; k++)
        sum += data[k] * x[indices[k]];       // только ненулевые!
    y[row] = sum;
}

Строки независимы — это map по строкам (C04), и первое CUDA-ядро очевидно: строка на тред:

__global__ void spmv_csr(int n, const int* indptr, const int* indices,
                         const float* data, const float* x, float* y)
{
    int row = blockIdx.x * blockDim.x + threadIdx.x;
    if (row < n) {
        float sum = 0.0f;
        for (int k = indptr[row]; k < indptr[row + 1]; k++)
            sum += data[k] * x[indices[k]];
        y[row] = sum;
    }
}

Ядро верное, но у него врождённая болезнь — дисбаланс нагрузки. Длина внутреннего цикла у каждого треда своя: строка-«звезда» соцсети с миллионом связей и строка-одиночка с двумя попадают в один варп, и 31 тред ждёт, пока тридцать второй дожуёт миллион слагаемых, — SIMT из C02 во всей красе, тот же эффект, что у границы множества Мандельброта в C08, только злее: там разброс был в сотни раз, здесь бывает в миллионы. Второй удар — доступ x[indices[k]]: индексы скачут по вектору произвольно, кэш почти не помогает; вот почему SpMV даже у профессионалов выжимает лишь малую долю пиковой пропускной способности.

Стандартное лечение дисбаланса — варп на строку: все 32 треда варпа совместно жуют одну строку (каждый берёт элементы с шагом 32), а потом складывают частичные суммы редукцией — тем самым деревом из C05, только внутри варпа. Длинная строка перестаёт держать соседей: варп занят ею целиком, а не одним тредом. Для матриц с ровными короткими строками (сетки!) хорош формат ELL с ядром «тред на строку»; универсальные библиотеки комбинируют подходы автоматически.

Что подводит к главному практическому совету: в продакшене SpMV не пишут руками — берут cuSPARSE, библиотеку NVIDIA из состава CUDA Toolkit (там же живут cuBLAS для плотных матриц из C07 и Thrust для сортировок из C08). Она выбирает алгоритм под портрет матрицы и обгоняет самописные ядра почти всегда. Своё ядро выше — учебное: оно нужно, чтобы понимать, почему у cuSPARSE такие ручки настройки и откуда берутся её ограничения.

Типичная ошибка Оценивать SpMV-ядро по метрике из C06 и паниковать от «жалких 10% пика». Для SpMV это не провал, а физика: нерегулярный доступ к x ломает слияние обращений к памяти, и значительная часть трафика тратится на подтягивание кэш-линий ради одного числа. Сравнивайте своё ядро не с пиком железа, а с cuSPARSE на той же матрице — вот честная линейка.

4. Python: scipy.sparse за пять минут

В Python разреженный мир давно собран в scipy.sparse — и все числа раздела 1 измерены именно им (скрипт c09_sparse_bench.py в папке сode):

import numpy as np
import scipy.sparse as sp

# случайная разреженная матрица: 10^8 клеток, 10^5 ненулевых
A = sp.random(10_000, 10_000, density=0.001, format="csr")

x = np.random.rand(10_000)
y = A @ x                          # SpMV: 0.076 мс против 16.4 мс у плотной

# те же три массива, что в разделе 2:
A.data, A.indices, A.indptr

Рабочие правила те же, что в C++, только именованные по-питоновски: собирать — в COO/LIL, считать — в CSR/CSC, конвертация между форматами дешёвая (.tocsr(), .tocoo()). Плотную матрицу из разреженной делает .toarray() — и это обычно последняя строка перед MemoryError: вызывайте её только на маленьких кусках. Связка с Numba из линии P прямая: @njit-функции охотно принимают тройку data/indices/indptr как обычные numpy-массивы — последовательный SpMV из раздела 3, переписанный на Python, в нашей проверке совпал со scipy до последнего знака.

5. Тренажёр: CSR на лету

Тренажёр · нарисуйте матрицу — получите формат

Щёлкайте по клеткам матрицы 8×8: клик создаёт ненулевой элемент (и убирает его повторным кликом). Массивы CSR, счётчик памяти и диагноз дисбаланса строк пересчитываются немедленно.

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

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

Источники

  1. Bell N., Garland M. Efficient Sparse Matrix-Vector Multiplication on CUDA : NVIDIA Technical Report NVR-2008-004. — NVIDIA Corporation, 2008. — URL: https://research.nvidia.com/publication/2008-12_efficient-sparse-matrix-vector-multiplication-cuda (дата обращения: 09.07.2026).
  2. cuSPARSE Library // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cusparse/ (дата обращения: 09.07.2026).
  3. Sparse arrays (scipy.sparse) // SciPy Documentation : [сайт]. — URL: https://docs.scipy.org/doc/scipy/reference/sparse.html (дата обращения: 09.07.2026).
  4. Sparse matrix // Wikipedia : [сайт]. — URL: https://en.wikipedia.org/wiki/Sparse_matrix (дата обращения: 09.07.2026).