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



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

Это руководство поясняет работу простейшей программы, производящей вычисления на 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. Korhog
    /#10605948 / -1

    Супир. Обожаю подобные статьи.

    • Korhog
      /#10605976

      Для полноты не хватает разве что анимированных гифок

  2. ser-mk
    /#10606630

    Спасибо за статью!
    а что вы имелли ввиду за «маленькую область 2х2 пространства фрактала»? Для чего она нужна?
    И еще момент непонятный остается. Программа просчитывает один раз текстуру и выходит? или периодически вызывается и обновляет тестуру?
    Можно и самому проверить, но пока нет возможности поставить здоровенный Unity.

    • ThisIsZolden
      /#10606668

      Фрактал Мандельброта ограничен координатами от -1 до 1 по х и у. А у пикселей координаты от 0 до 1023. Нужно проецировать, чтоб фрактал на весь экран был.

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

  3. leshabirukov
    /#10606682

    cuda программируется точно также, только выходные данные идут не на рендеринг, а выгружаются обратно в память.

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

    Вопрос скорее, зачем бить на квадраты 32x32. А дело в том, что пачка данных одновременно обрабатываемых мультипроцессором это 32 треда в одном варпе (разделение по вычислительным блокам) Х 32 варпа (разделение по времени), и важно кормить эту гидру синхронизированными данными, к примеру читать память так:
    var = mem[ id.x ]
    хорошо, а так:
    var = mem[ id.x *1000 ]
    плохо.

    • ThisIsZolden
      /#10606710

      Я этого не знал, но вы вероятно правы. Хотя, иногда нет другого выхода, кроме как делать примерно так:
      var = buffer[id.x * height + id.y]
      потому что буфферы одномерные, а данные частенько двумерные, а структуру потоков делать одномерной вроде бы тоже не очень эффективно, да и лимиты есть по каждой оси.

      • SmallSnowball
        /#10606868 / +1

        Можно поменять местами id.x и id.y внутри kernel'а и делать так:
        var = buffer[id.y * height + id.x]

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

    • ser-mk
      /#10607382

      cuda программируется точно также, только выходные данные идут не на рендеринг, а выгружаются обратно в память.

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

      записать значения в какой-нибудь буфер и потом уже работать с этим буфером.

      • leshabirukov
        /#10608226

        Я имел в виду, записываются обратно в память процессора из памяти видеокарты. Всё что вы видите в kernel-части, происходит внутри видеокарты, и к примеру, на диск вы результат работы шейдера не запишете.

  4. alexoron
    /#10606820

    Да кому интересно делать какие-то «пустые» вычисления, если можно майнить с одной видеокарты от $2,5 в сутки.
    А поставь таких видях с десяток и на работу не нужно ходить.

  5. avtor13
    /#10606958

    прочитав заголовок и первый абзац решил, что речь пойдет о GPGPU