Почему (x / y)[i] быстрее, чем x[i] / y[i]?

Вопрос или проблема

Я новичок в 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, они часто сильно зависят от соотношения между затратами на вычисления и затратами на доступ к памяти. В данном случае, сравниваются две разные последовательности действий, и их производительность будет определяться тем, как они управляют памятью и вычислениями.

Анализ операций

  1. (x[i] / y[i]):
    • Чтение индексов: Сначала мы получаем все значения из массива (i). Это требует 1 миллион операций чтения.
    • Чтение массивов: Затем необходимо выполнить чтение значений из массивов (x) и (y) на основе индексов (i). Поскольку на GPU данные читаются группами (например, по 32 байта), требует значительные объемы памяти, чтобы обращаться ко всем необходимым элементам из (x) и (y). В результате у нас может получиться до 2 миллионов чтений.
    • Выборка и деление: После получения значений будет выполнена операция деления. Здесь происходит около 500,000 операций деления.
    • Запись результата: Наконец, результаты записываются, что требует еще 500,000 операций записи.

Итак, общее количество операций для (x[i] / y[i]) будет достаточно высоким: чтение массивов, выборка данных, деление и запись результатов.

  1. ((x / y)[i]):
    • Чтение индексов: Также начинается с чтения индексов (i) — 1 миллион операций чтения.
    • Чтение массивов: Далее одновременно считываются все элементы из (x) и (y) — 2 миллиона чтений. Здесь вся операция деления выполняется для всех элементов сразу.
    • Деление: Проводится 1 миллион операций деления для каждого элемента (по сути, 1 операция на элемент).
    • Выборка и запись результата: Затем производится выборка результата на основе (i) и запись результатов, что составит еще 500,000 операций записи.

Общие выводы

Главное различие заключается в количестве операций выборки данных и деления. В случае ((x / y)[i]) мы сначала выполняем вычисление деления для всех элементов, а потом делаем выборку, что снижает количество операций выбора данных и осуществляет деления более эффективно, за счет чтения данных только один раз с наименьшими затратами на память.

Оптимизация производительности

При работе с данным подходом на GPU и при необходимости выбора наилучшего метода следует учитывать следующие моменты:

  • Архитектура GPU: Разные GPU имеют различные соотношения между производительностью вычислений и пропускной способностью памяти. Изучите характеристики вашего GPU.
  • Объем операций памяти: Старайтесь минимизировать количество операций чтения и записи. Чем меньше операций с памятью, тем быстрее будет выполняться ваш код.
  • Разделяйте вычисления и выборку: Выполняйте вычисления в одном шаге (группой) перед выборкой, чтобы снизить общий объем необходимых операций.

Таким образом, вы сможете более эффективно использовать ресурсы GPU и оптимизировать выполнение ваших программ на CuPy и других библиотек.

Оцените материал
Добавить комментарий

Капча загружается...