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

Меряем время: события CUDA и пропускная способность

О чём эта тема
Как честно измерить скорость GPU-кода: события CUDA вместо системных часов, SAXPY как эталонная задача, эффективная пропускная способность в гигабайтах в секунду — и трезвый разговор о том, когда видеокарта проигрывает процессору.
Аннотация
«Стало быстрее» — не измерение. Конспект начинается с инструмента: пара событий cudaEvent ставит метки прямо в очередь работы устройства и мерит миллисекунды между ними — системные часы хоста для асинхронных запусков непригодны. Затем — эталонная задача SAXPY из библиотеки BLAS: одна строка математики, на которой видно всё. Для неё вводится главная метрика конспекта — эффективная пропускная способность: сколько байт в секунду программа реально прогоняет через память, и какая доля это от паспортного потолка карты. Дальше — неудобная правда: замер с учётом cudaMemcpy, кейс «увеличить каждый элемент на единицу», где копирование съедает выигрыш целиком, и список ситуаций, когда CPU выигрывает. Попутно — проверка ошибок CUDA, которую давно пора было завести. Тренажёр — калькулятор пропускной способности с полосой «доля от пика».
Пререквизиты
C04 — сложение векторов (SAXPY — его близнец); C03 — глобальная память и PCIe-копирования.
Мотивация
Вся траектория держится на обещании «GPU быстрее». Пришло время предъявлять доказательства — и научиться их читать. Число «ядро отработало за 1.2 мс» само по себе пусто: хорошо это или провал? Метрика GB/s превращает его в диагноз: «программа использует 85% возможностей памяти — быстрее почти некуда» или «12% — где-то течёт». А замер с копированиями отвечает на вопрос, который вам зададут первым: «а с учётом всего — стоило ли вообще переносить данные на видеокарту?»

1. Часы для асинхронного мира: cudaEvent

Первый порыв — обложить запуск ядра системными часами. Ловушка в асинхронности (C01): хост «пробегает» строку запуска, не дожидаясь выполнения, и часы намерят время постановки задачи в очередь — микросекунды вместо настоящих миллисекунд. Можно вставить cudaDeviceSynchronize перед остановкой часов, но есть инструмент точнее — события CUDA: метки, которые устройство само проставляет, проходя очередь работы.

cudaEvent_t start, stop;
float ms = 0;

cudaEventCreate(&start);
cudaEventCreate(&stop);

cudaEventRecord(start);            // метка «до» — в очередь устройства
kernel<<<grid, block>>>(…);
cudaEventRecord(stop);             // метка «после» — туда же

cudaEventSynchronize(stop);        // подождать, пока устройство дойдёт до метки
cudaEventElapsedTime(&ms, start, stop);   // миллисекунды между метками

printf("ядро: %.3f мс\n", ms);
cudaEventDestroy(start);
cudaEventDestroy(stop);

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

kernel<<<grid, block>>>(…);
cudaError_t err = cudaGetLastError();          // ошибка самого запуска
if (err != cudaSuccess)
    printf("CUDA: %s\n", cudaGetErrorString(err));
Типичная ошибка Гордиться замером «0.003 мс» на ядре, которое даже не запустилось: например, блок в 2048 тредов превышает лимит, ядро отвергнуто, очередь пуста — события зафиксировали пустоту, а программа промолчала. Подозрительно круглые нули в замерах — повод первым делом спросить cudaGetLastError, а не открывать шампанское.

2. SAXPY: эталонный подопытный

SAXPY — Single-precision A·X Plus Y, операция y = a·x + y из библиотеки BLAS: вектор x умножается на скаляр a и прибавляется к вектору y. Одна строка математики, map-паттерн, ровно по два чтения и одной записи на элемент — идеальная линейка для измерения памяти. Ядро — близнец vecAdd из C04:

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

// запуск на 4 миллиона элементов, замер событиями — по скелету раздела 1
const int n = 4 * 1024 * 1024;
saxpy<<<(n + 255) / 256, 256>>>(n, 2.0f, dx, dy);

Пусть события намерили t миллисекунд. Что это значит? Считаем байты. На каждый элемент ядро читает x[i] и y[i] (8 байт) и пишет y[i] (4 байта) — всего 12 байт; арифметики — две операции. Соотношение «12 байт на 2 флопа» означает, что задача упирается не в счёт, а в память, и мерить её надо байтами:

(6.1)

эффективная пропускная способность = (байт прочитано + байт записано) / время = 3 · n · 4 байта / t

Например, n = 4 194 304 элемента, t = 0.19 мс: 12 · 4 194 304 / 0.00019 с ≈ 265 ГБ/с. Много это или мало — зависит от карты: у каждой в паспорте есть теоретическая пропускная способность памяти (например, 360 ГБ/с у GeForce RTX 3060, 320 у Tesla T4 из Colab). 265 из 360 — это 74% потолка: для одной строки кода отлично. Хорошо написанные memory-bound ядра выжимают 70–90% паспорта; если у вас 15% — ядро что-то делает не так (чаще всего — вразнобой читает память), и наоборот: ядро на 85% пика бессмысленно оптимизировать дальше — байтам физически некуда ускоряться.

3. Честный замер: а теперь с копированиями

Замер раздела 2 — время ядра. Но данные на устройство надо скопировать, а результат — скопировать обратно (C03), а PCIe даёт в лучшем случае десятки ГБ/с — на порядок меньше памяти видеокарты. Рассмотрим показательный антипример: «увеличить каждый элемент массива на единицу» — одно чтение, одна запись, одно сложение:

cudaEventRecord(start);
cudaMemcpy(dev, a, bytes, cudaMemcpyHostToDevice);   // туда
inc<<<grid, block>>>(dev, n);                        // +1 каждому
cudaMemcpy(a, dev, bytes, cudaMemcpyDeviceToHost);   // обратно
cudaEventRecord(stop);

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

Типичная ошибка Сравнивать «GPU без копирований» против «CPU со всем». Убедительно выглядит в отчёте, но при первой же попытке встроить код в реальный конвейер выясняется, что данные-то у вас на хосте. Честных протокола два: либо оба замера «под ключ» (с копированием данных), либо оба «чистое вычисление» с оговоркой, что данные уже на устройстве, — и в статье или отчёте всегда написано, который из двух вы используете.

4. Тренажёр: калькулятор пропускной способности

Тренажёр · GB/s и доля от пика

Подставьте свой замер — калькулятор посчитает эффективную пропускную способность по формуле (6.1) и покажет долю от паспортного потолка выбранной карты. Начальные значения — пример из раздела 2.

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

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

Источники

  1. How to Implement Performance Metrics in CUDA C/C++ // NVIDIA Developer Blog : [сайт]. — URL: https://developer.nvidia.com/blog/how-implement-performance-metrics-cuda-cc/ (дата обращения: 09.07.2026).
  2. Six Ways to SAXPY // NVIDIA Developer Blog : [сайт]. — URL: https://developer.nvidia.com/blog/six-ways-saxpy/ (дата обращения: 09.07.2026).
  3. CUDA C++ Best Practices Guide. Performance Metrics // NVIDIA Docs : [сайт]. — URL: https://docs.nvidia.com/cuda/cuda-c-best-practices-guide/ (дата обращения: 09.07.2026).