Память GPU: шесть этажей
- О чём эта тема
- Иерархия памяти видеокарты — от регистров до глобальной DRAM, — функции cudaMalloc / cudaMemcpy / cudaFree и жизненный цикл данных «хост → устройство → хост», без которого не работает ни одна CUDA-программа.
- Аннотация
- У видеокарты не одна память, а шесть, и отличаются они друг от друга скоростью на два порядка. Конспект расставляет их по этажам: регистры и разделяемая память живут прямо на мультипроцессоре и работают почти мгновенно; глобальная, локальная, константная и текстурная — в DRAM видеокарты, до которой сотни тактов. Для каждого типа — кто его видит, сколько он живёт и когда его выбирать; всё сведено в таблицу и собственную схему. Практическая часть — протокол работы с глобальной памятью: выделить, скопировать с хоста, посчитать, скопировать обратно, освободить — с разбором каждой функции и её ошибок. Отдельно — как устроены банки разделяемой памяти и что такое конфликт банков. Тренажёр предлагает шесть сценариев: выберите правильный этаж для данных.
- Пререквизиты
- C01 — компиляция; C02 — грид, блоки, треды, SM: типы памяти привязаны ровно к этой иерархии.
- Мотивация
- В C02 ядро-«перекличка» ничего не вычисляло — ему не нужны были данные. Настоящему ядру нужны: массивы на входе, массивы на выходе. Но у хоста и устройства разные памяти: указатель на обычный массив C++ для видеокарты — бессмысленный адрес в чужом пространстве. Значит, данные придётся копировать между этими памятями, и то, как вы это делаете и где храните, определяет скорость программы сильнее, чем само вычисление: у опытных CUDA-программистов оптимизация почти всегда начинается со слова «память».
1. Две разные памяти и мост между ними
Оперативная память компьютера принадлежит хосту. У видеокарты — своя собственная DRAM (на коробке её называют «видеопамять», 8–24 ГБ у современных карт). Процессор не умеет напрямую адресовать память видеокарты, а видеокарта — память хоста; между ними — шина PCIe, по которой данные копируются. Отсюда протокол любой CUDA-программы, который вы уже видели в схеме и теперь выучите руками:
cudaMalloc
cudaMemcpy H2D
<<<…>>>
cudaMemcpy D2H
cudaFree
Три функции моста:
// выделить size байт в глобальной памяти устройства cudaMalloc((void**)&dev_ptr, size); // скопировать size байт; последний аргумент — направление: // cudaMemcpyHostToDevice, cudaMemcpyDeviceToHost, cudaMemcpyDeviceToDevice cudaMemcpy(dst, src, size, cudaMemcpyHostToDevice); // освободить память устройства cudaFree(dev_ptr);
Все три возвращают код cudaError_t: cudaSuccess при удаче, иначе — код ошибки (неверный указатель, перепутанное направление, нехватка памяти). Привычка проверять эти коды спасает часы отладки; систематически займёмся этим в C06, а пока запомните функцию-переводчик cudaGetErrorString(err) — она превращает код в человекочитаемую строку.
2. Шесть этажей: от регистров до текстур
Теперь заглянем внутрь устройства. Память видеокарты делится на шесть типов, и ключ к пониманию — где физически находится каждый: прямо на кристалле мультипроцессора (быстро) или в DRAM за его пределами (сотни тактов латентности).
| Тип | Где физически | Кто видит | Доступ | Время жизни | Скорость |
|---|---|---|---|---|---|
| регистры | SM | один тред | чтение/запись | ядро | максимальная |
| локальная | DRAM | один тред | чтение/запись | ядро | медленно (это DRAM!) |
| разделяемая | SM | треды блока | чтение/запись | блок | близко к регистрам |
| глобальная | DRAM | все треды + хост | чтение/запись | пока не cudaFree | сотни тактов, кэш L2 |
| константная | DRAM + кэш | все треды (чтение), хост (запись) | только чтение из ядра | программа | быстро при общем чтении |
| текстурная | DRAM + кэш | все треды (чтение) | только чтение из ядра | программа | быстро при 2D-локальности |
Три уточнения, на которых часто спотыкаются старые учебники:
- Разделяемая память — быстрая. Она стоит на кристалле рядом с регистрами, и обращение к ней занимает единицы тактов (если нет конфликтов банков — о них ниже). Медленной её называют только по ошибке.
- Локальная память — медленная, несмотря на уютное название: «локальная» она по видимости (личная для треда), а физически это та же DRAM. Компилятор сгружает туда переменные, когда треду не хватило регистров, и большие массивы, объявленные внутри ядра. Если профилировщик показывает «register spilling» — программа внезапно ходит в DRAM на каждой итерации.
- Глобальная память находится на видеокарте, а не «на центральном процессоре»: её латентность — плата за DRAM вне кристалла, а не за обращение к хосту. С хостом (через PCIe) обменивается данными только cudaMemcpy, и эта передача ещё на порядок дороже.
Константная память заслуживает одного абзаца практики: если все треды читают одни и те же неизменные данные — коэффициенты формулы, параметры модели, — их объявляют глобальной переменной со спецификатором __constant__ и заполняют с хоста функцией cudaMemcpyToSymbol. Чтение через константный кэш при таком сценарии почти бесплатно:
__constant__ float coeffs[64]; // объявление на уровне файла // на хосте: заполнить из обычного массива cudaMemcpyToSymbol(coeffs, host_coeffs, 64 * sizeof(float)); // в ядре coeffs читается как обычный массив
Текстурная память — специализированный инструмент из графического прошлого GPU: кэш, оптимизированный под чтение «соседних» точек двумерных данных, плюс аппаратная интерполяция. В вычислительных программах её ниша узкая (обработка изображений), и в этой траектории мы ограничимся знакомством.
4. Тренажёр: куда положить данные
Шесть сценариев из реальных программ. Для каждого выберите тип памяти, который здесь уместнее всего; после ответа — объяснение.
Контрольные вопросы
-
cudaMalloc — выделить память устройства; cudaMemcpy (HostToDevice) — скопировать вход; запуск ядра — посчитать; cudaMemcpy (DeviceToHost) — забрать результат; cudaFree — освободить. Хост и устройство не видят память друг друга напрямую, поэтому без копирований не обойтись.
-
Локальная она только по видимости: принадлежит одному треду. Физически это область в DRAM видеокарты — туда компилятор сгружает переменные при нехватке регистров и большие массивы, объявленные в ядре. Латентность у неё как у глобальной памяти — сотни тактов.
-
Разделяемая стоит на кристалле SM: латентность в единицы тактов, близко к регистрам; видна только тредам одного блока и живёт, пока работает блок. Глобальная — в DRAM: сотни тактов; видна всем тредам грида и хосту, живёт до cudaFree.
-
Когда все треды читают одни и те же неизменяемые данные — коэффициенты, параметры. Объявляется глобальная переменная __constant__, заполняется с хоста через cudaMemcpyToSymbol; чтение идёт через отдельный кэш и при общем чтении почти бесплатно. Из ядра эта память доступна только на чтение.
-
Разделяемая память разбита на 32 банка по 32-битным словам. Если треды варпа обращаются к разным словам одного банка, обращения выполняются по очереди — варп замедляется кратно глубине очереди. Классика: чтение столбца матрицы 32×32 float — весь столбец лежит в одном банке, конфликт 32-го порядка. Чтение одного и того же слова всеми тредами конфликтом не является.
-
Ядро получит адрес из адресного пространства хоста, который на устройстве ничему не соответствует: чтение вернёт мусор либо ядро упадёт с illegal memory access. Компилятор ошибку не поймает — типы совпадают. В ядро можно передавать только указатели, выделенные cudaMalloc.
Источники
- CUDA C++ Programming Guide. Memory Hierarchy // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cuda-c-programming-guide/ (дата обращения: 09.07.2026).
- CUDA C++ Best Practices Guide. Memory Optimizations // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/ (дата обращения: 09.07.2026).
- Using Shared Memory in CUDA C/C++ // NVIDIA Developer Blog : [сайт]. — URL: https://developer.nvidia.com/blog/using-shared-memory-cuda-cc/ (дата обращения: 09.07.2026).