Вычисления на видеокарте, руководство, лёгкий уровень

  • Tutorial
Это руководство поясняет работу простейшей программы, производящей вычисления на GPU. Вот ссылка на проект Юнити этой программы:

ссылка на файл проекта .unitypackage

Она рисует фрактал Мандельброта.

Я не буду пояснять каждую строчку кода, укажу только необходимые действия для реализации вычислений на GPU. Поэтому, лучше всего открыть код программы в Юнити и там смотреть, как используются поясняемые мной строчки кода.

Шейдер, который рисует фрактал, написан на языке HLSL. Ниже приведён его текст. Я кратко прокомментировал значимые строки, а развёрнутые объяснения будут ниже.

// выполняющаяся в GPU программа использует данные из видеопамяти через буфферы:

RWTexture2D<float4> textureOut;						// это текстура, в которую мы будем записывать пиксели
RWStructuredBuffer<double> rect;					// это границы области в пространстве фрактала, которую мы визуализируем
RWStructuredBuffer<float4> colors;					// а это гамма цветов, которую мы подготовили на стороне CPU и передали в видеопамять

#pragma kernel pixelCalc							// тут мы объявили кернел, по этому имени мы сможем его выполнить со стороны CPU
[numthreads(32,32,1)]								// эта директива определяет количество потоков, в которыз выполнится этот кернел
void pixelCalc (uint3 id : SV_DispatchThreadID){	// тут мы задаём код кернела. Параметр id хранит индекс потока, который используется для адресации данных
	float k = 0.0009765625;							// это просто множитель для проекции пространства 1024х1024 текстуры на маленькую область 2х2 пространства фрактала
	double dx, dy;
	double p, q;
	double x, y, xnew, ynew, d = 0;					// использованы переменные двойной точности, чтобы отдалить столкновение с пределом точности при продвижении вглубь фрактала
	uint itn = 0;
	dx = rect[2] - rect[0];
	dy = rect[3] - rect[1];
	p = rect[0] + ((int)id.x) * k * dx;
	q = rect[1] + ((int)id.y) * k * dy;
	x = p;
	y = q;
	while (itn < 255 && d < 4){						// собственно суть фрактала: в этом цикле вычисляется число шагов, за которые точка покидает пространство 2x2
		xnew = x * x - y * y + p;
		ynew = 2 * x * y + q;
		x = xnew;
		y = ynew;
		d = x * x + y * y;
		itn++;
	}
	textureOut[id.xy] = colors[itn];				// вот так мы записываем пиксель цвета: пиксель текстуры определяется индексом, а индекс цвета - числом шагов
}

Внимательный читатель скажет: автор, поясни! Размер текстуры — 1024х1024, а количество потоков — 32х32. Как же параметр id.xy адресует все пиксели текстуры?
Внимательный, но неопытный в вопросах вычислений на GPU читатель перебьёт: позвольте! А откуда следует, что количество потоков 32x32? И как понимать «id.xy»?

Второму я отвечу так: директива [numthreads(32,32,1)] говорит, что у нас 32х32х1 потоков. При этом, потоки образуют трёхмерную сетку, потому что параметр id принимает значения в виде координат пространства 32x32x1. Диапазон значений id.x [0, 31], диапазон значений id.y [0, 31], а id.z равен 0. А id.xy — это краткая запись uint2(id.x, id.y)

Именно 32x32 потоков у нас было бы (этой я уже отвечаю первому внимательному читателю), если бы мы вызвали этот кернел со стороны CPU командой

ComputeShader.Dispatch(kernelIndex, 1, 1, 1)

Видите эти три единицы? Это то же самое, что цифры в директиве [numthreads(32,32,1)], они умножаются друг с другом.

Если бы мы запустили шейдер вот с такими параметрами:

ComputeShader.Dispatch(kernelIndex, 2, 4, 1)

То по оси x у нас было бы 32 * 2 = 64, по оси у 32 * 4 = 128, то есть всего — 64х128 потоков. Параметры просто перемножаются по каждой оси.

Но нашем случае кернел запущен так:

ComputeShader.Dispatch(kernelIndex, 32, 32, 1)

Что даёт нам в итоге 1024х1024 потока. И значит, индекс id.xy будет принимать значения, покрывающие всё пространство текстуры 1024х1024

Так сделано для удобства. Даные хранятся в массивах, каждый поток осуществляет одну и ту же операцию над единицей данных, и мы создаём количество потоков равным количеству единиц данных, при чём так, чтобы индекс потока адресовал свою единицу данных. Очень удобно.

Вот и всё, что нужно знать про шейдерный код нашей фракталорисующей программы.

Теперь рассмотрим, что мы сделали на стороне CPU, чтобы запустить шейдерный код.

Объявляем переменные: шейдер, буффер и текстуру

ComputeShader _shader
RenderTexture outputTexture
ComputeBuffer colorsBuffer

Инициализируем текстуру, не забыв включить enableRandomWrite

outputTexture = new RenderTexture(1024, 1024, 32);
outputTexture.enableRandomWrite = true;
outputTexture.Create();

Инииализируем буффер, задав количество объектов и размер объекта. И записываем данные предварительно наполненного массива цветов в видеопамять

colorsBuffer = new ComputeBuffer(colorArray.Length, 4 * 4);
colorsBuffer.SetData(colorArray);

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

_shader = Resources.Load<ComputeShader>("csFractal");
kiCalc = _shader.FindKernel("pixelCalc");
_shader.SetBuffer(kiCalc, "colors", colorsBuffer);
_shader.SetTexture(kiCalc, "textureOut", outputTexture);

В этом состоит подготовка данных. Теперь остаётся только запустить кернел шейдера

_shader.Dispatch(kiCalc, 32, 32, 1);

После выполнения этой команды текстура заполняется цветами, которые мы сразу видим, потому что текстура RenderTexture использована в качестве mainTexture для компонента Image, на который смотрит камера.
Поделиться публикацией
Комментарии 12
    –1
    Супир. Обожаю подобные статьи.
      0
      Для полноты не хватает разве что анимированных гифок
      0
      Спасибо за статью!
      а что вы имелли ввиду за «маленькую область 2х2 пространства фрактала»? Для чего она нужна?
      И еще момент непонятный остается. Программа просчитывает один раз текстуру и выходит? или периодически вызывается и обновляет тестуру?
      Можно и самому проверить, но пока нет возможности поставить здоровенный Unity.
        0
        Фрактал Мандельброта ограничен координатами от -1 до 1 по х и у. А у пикселей координаты от 0 до 1023. Нужно проецировать, чтоб фрактал на весь экран был.

        Программа перерисовывает фрактал каждый раз, как пользователь изменит масштаб или позицию выводящего прямоугольника. Так что можно путешествовать по красочным прядям фрактала.
        0
        cuda программируется точно также, только выходные данные идут не на рендеринг, а выгружаются обратно в память.
        Внимательный, но неопытный в вопросах вычислений на GPU читатель перебьёт: позвольте! А откуда следует, что количество потоков 32x32? И как понимать «id.xy»?

        Вопрос скорее, зачем бить на квадраты 32x32. А дело в том, что пачка данных одновременно обрабатываемых мультипроцессором это 32 треда в одном варпе (разделение по вычислительным блокам) Х 32 варпа (разделение по времени), и важно кормить эту гидру синхронизированными данными, к примеру читать память так:
        var = mem[ id.x ]
        хорошо, а так:
        var = mem[ id.x *1000 ]
        плохо.
          0
          Я этого не знал, но вы вероятно правы. Хотя, иногда нет другого выхода, кроме как делать примерно так:
          var = buffer[id.x * height + id.y]
          потому что буфферы одномерные, а данные частенько двумерные, а структуру потоков делать одномерной вроде бы тоже не очень эффективно, да и лимиты есть по каждой оси.
            +1
            Можно поменять местами id.x и id.y внутри kernel'а и делать так:
            var = buffer[id.y * height + id.x]

            Правда, в таком случае надо об этом везде помнить, да и разные алгоритмы могут требовать разной укладки многомерного буффера в памяти. В некоторых случаях выгода от локальности данных внутри варпа может быть настолько большой, что имеет смысл в рантайме менять раскладку данных внутри буффера.
            0
            cuda программируется точно также, только выходные данные идут не на рендеринг, а выгружаются обратно в память.

            Так здесь разве нельзя так же поступить? в последней строчке вместо
            textureOut[id.xy] = colors[itn];	

            записать значения в какой-нибудь буфер и потом уже работать с этим буфером.
              0
              Я имел в виду, записываются обратно в память процессора из памяти видеокарты. Всё что вы видите в kernel-части, происходит внутри видеокарты, и к примеру, на диск вы результат работы шейдера не запишете.
            0
            Да кому интересно делать какие-то «пустые» вычисления, если можно майнить с одной видеокарты от $2,5 в сутки.
            А поставь таких видях с десяток и на работу не нужно ходить.
              0
              Так не ходи.
              0
              прочитав заголовок и первый абзац решил, что речь пойдет о GPGPU

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое