Вопрос или проблема
Я новичок в CuPy
и вычислениях с использованием CUDA/GPU. Можете объяснить, почему (x / y)[i]
быстрее, чем x[i] / y[i]
?
При использовании ускоренных вычислений на GPU существуют ли какие-либо рекомендации, которые позволят мне быстро определить, какая операция быстрее? Как избежать необходимости тестировать каждую операцию.
# В Jupyter Notebook на VSCode
import cupy as cp
from cupyx.profiler import benchmark
x = cp.arange(1_000_000)
y = (cp.arange(1_000_000) + 1) / 2
i = cp.random.randint(2, size=1_000_000) == 0
x, y, i
# Результат:
(array([ 0, 1, 2, ..., 999997, 999998, 999999], shape=(1000000,), dtype=int32),
array([5.000000e-01, 1.000000e+00, 1.500000e+00, ..., 4.999990e+05,
4.999995e+05, 5.000000e+05], shape=(1000000,), dtype=float64),
array([ True, False, True, ..., True, False, True], shape=(1000000,), dtype=bool))
def test1(x, y, i):
return (x / y)[i]
def test2(x, y, i):
return x[i] / y[i]
print(benchmark(test1, (x, y, i)))
print(benchmark(test2, (x, y, i)))
# Результат:
test1: CPU: 175.164 us +/- 61.250 (min: 125.200 / max: 765.100) us GPU-0: 186.001 us +/- 67.314 (min: 134.144 / max: 837.568) us
test2: CPU: 342.364 us +/- 130.840 (min: 223.000 / max: 1277.600) us GPU-0: 368.133 us +/- 136.911 (min: 225.504 / max: 1297.408) us
Соотношение арифметических возможностей и возможностей извлечения байтов на GPU (по крайней мере, возможно, и на CPU) обычно несбалансировано в пользу арифметических возможностей. Поэтому производительность алгоритмов часто можно предсказать по количеству требуемых операций памяти. Первый шаг в реализации x[i]/y[i]
– создание x[i]
и y[i]
, что является чисто операциями с памятью – компактизация потока. Затем происходит уменьшение уровня арифметики. В другой реализации сначала происходит более высокий уровень арифметики, за которым следует сокращение операций памяти на 50%.
Современный графический процессор для дата-центров, например, A100, может иметь ~10 ТФ производительности FP64 и ~2 ТБ/с полосы пропускания памяти, так что это соотношение составляет 5 FP64 FLOP на байт данных, прочитанных или записанных. Для 8-байтового FP64 значения это 40 FLOP на каждый FP64 значение, прочитанное или записанное. (Деление требует множества FLOP.)
Предположим, что массив i
состоит на 50% из True и на 50% из False. Теперь давайте посчитаем все необходимые шаги.
В реализации x[i]/y[i]
мы должны:
- прочитать весь массив
i
– 1,000,000 чтений. - использовать его для чтения части массивов
x
иy
, но на GPU вы не можете читать отдельные элементы. Вы читаете память порциями по 32 байта за раз, поэтому, если распределение True/False равномерное, вам может потребоваться прочитать весь массивx
иy
– 2,000,000 чтений - сделать компактизацию потока для
x
,y
в соответствии сi
- выполнить арифметическое деление – 500,000 операций деления.
- записать результаты – 500,000 записей
В реализации (x/y)[i]
:
- прочитать весь массив
i
– 1,000,000 чтений. - прочитать весь массив
x
иy
– 2,000,000 чтений. - выполнить арифметическое деление – 1,000,000 операций деления.
- сделать компактизацию потока для результата деления в соответствии с
i
- записать результаты – 500,000 записей
Единственное отличие заключается в расположении операций компактизации потока и количестве необходимых операций деления. Компактизация потока, по сути, является чисто задачей, связанной с памятью, поэтому разница заключается в стоимости доступа к памяти. Первая схема требует в два раза больше компактизации потока.
На GPU, по крайней мере, FLOP часто значительно “дешевле”, чем чтение/запись данных.
Соотношение True/False в i
не влияет на этот анализ для A100. Другие GPU, такие как T4, имеют относительно низкую производительность для FP64 по сравнению с FP32. T4 имеет соотношение 320 ГБ/с полосы пропускания памяти к ~8 ТФ производительности FP32, но только 1/8 ТФ или 125 ГФ производительности FP64. При высоком соотношении True к False это не будет иметь значения, но при низком соотношении True к False для T4 возможно, что стоимость деления становится фактором. Эта разница, вероятно, исчезнет при переходе к типу данных FP32. Другими словами, для FP64 операций T4 не обязательно удовлетворяет начальной предпосылке моего ответа: “Соотношение арифметической способности к возможности извлечения байтов на GPU (по крайней мере, возможно, и на CPU) обычно несбалансировано в пользу арифметической способности.”
Ответ или решение
Вопрос о том, почему выражение ((x / y)[i]) выполняется быстрее, чем (x[i] / y[i]), имеет важные аспекты, касающиеся архитектуры графических процессоров (GPU) и оптимизации памяти.
Когда мы говорим об операциях на GPU, они часто сильно зависят от соотношения между затратами на вычисления и затратами на доступ к памяти. В данном случае, сравниваются две разные последовательности действий, и их производительность будет определяться тем, как они управляют памятью и вычислениями.
Анализ операций
- (x[i] / y[i]):
- Чтение индексов: Сначала мы получаем все значения из массива (i). Это требует 1 миллион операций чтения.
- Чтение массивов: Затем необходимо выполнить чтение значений из массивов (x) и (y) на основе индексов (i). Поскольку на GPU данные читаются группами (например, по 32 байта), требует значительные объемы памяти, чтобы обращаться ко всем необходимым элементам из (x) и (y). В результате у нас может получиться до 2 миллионов чтений.
- Выборка и деление: После получения значений будет выполнена операция деления. Здесь происходит около 500,000 операций деления.
- Запись результата: Наконец, результаты записываются, что требует еще 500,000 операций записи.
Итак, общее количество операций для (x[i] / y[i]) будет достаточно высоким: чтение массивов, выборка данных, деление и запись результатов.
- ((x / y)[i]):
- Чтение индексов: Также начинается с чтения индексов (i) — 1 миллион операций чтения.
- Чтение массивов: Далее одновременно считываются все элементы из (x) и (y) — 2 миллиона чтений. Здесь вся операция деления выполняется для всех элементов сразу.
- Деление: Проводится 1 миллион операций деления для каждого элемента (по сути, 1 операция на элемент).
- Выборка и запись результата: Затем производится выборка результата на основе (i) и запись результатов, что составит еще 500,000 операций записи.
Общие выводы
Главное различие заключается в количестве операций выборки данных и деления. В случае ((x / y)[i]) мы сначала выполняем вычисление деления для всех элементов, а потом делаем выборку, что снижает количество операций выбора данных и осуществляет деления более эффективно, за счет чтения данных только один раз с наименьшими затратами на память.
Оптимизация производительности
При работе с данным подходом на GPU и при необходимости выбора наилучшего метода следует учитывать следующие моменты:
- Архитектура GPU: Разные GPU имеют различные соотношения между производительностью вычислений и пропускной способностью памяти. Изучите характеристики вашего GPU.
- Объем операций памяти: Старайтесь минимизировать количество операций чтения и записи. Чем меньше операций с памятью, тем быстрее будет выполняться ваш код.
- Разделяйте вычисления и выборку: Выполняйте вычисления в одном шаге (группой) перед выборкой, чтобы снизить общий объем необходимых операций.
Таким образом, вы сможете более эффективно использовать ресурсы GPU и оптимизировать выполнение ваших программ на CuPy и других библиотек.