где a, b – размеры пластинки
δ – дельта-функция
x’, y’ – координаты единичной сосредоточенной силы.
Ну вот, с математикой разобрались.
Хост
Та часть кода, которая выполняется на процессоре, называется хостом (host). Часть программы, выполняемая на видеокарте, называется ядром (kernel).
Создадим проект. Визуализировать нашу мембрану мы будем с помощью DirectX, поэтому подключаем его, создаём устройство, камеру – всё как обычно, на этом останавливаться не будем — статья не про то.
Для визуализации нам понадобится массив смещений мембраны. Размер выберем для него 128 на 128. Этого хватит чтобы «убить» процессор.
Почему я создал одномерный массив, скажу потом. Также нам понадобятся параметры мембраны:
float a;
float b;
float c;
float s;
float m;
int p;
Еще понадобится буфер вершин для отрисовки мембраны. Рисовать будем линиями. Кто хочет — может сделать красивее, меня так устраивает.
IDirect3DVertexBuffer9* m_VB;
И создадим его:
m_Color = 0xff0000;
HRESULT hr = 0;
hr = m_Device->CreateVertexBuffer( sizeof(s_ColoredPoint)*128*128,
D3DUSAGE_WRITEONLY,
ColoredPointFVF,
D3DPOOL_MANAGED,
&m_VB,0);
Для начала сделаем колебания мембраны на процессоре. Для этого нам понадобятся три функции:
void Compute(float _t)
{
int iu = 0;
for(int i = 0; i < 128; i=i+2)
{
for(int j = 0; j < 128; j++)
{
m_height[i*128 + j] = U(i*0.1, j*0.1, _t);
}
}
}
float U(float _x, float _y, float _t)
{
float _U = 0.0;
for(int n = 1; n < p; n++)
{
float tmp = 0.0f;
float qj = n*n*9.869f/(b*b);
for(int m = 1; m < p; m++)
{
float i = m*PI/a;
tmp += cos(sqrt(qj + i*i)*c*_t)*sin(m*PI*_x/a)*sin(m*1.57f);
}
_U += tmp*sin(n*PI*_y/b)*sin(n*1.57f);
}
return _U*4.0f/(a*b*s);
}
void FillBuffer()
{
s_ColoredPoint* _pBuf;
m_VB->Lock( 0, sizeof(s_ColoredPoint)*128*128, (void**)&_pBuf, 0 );
int iu = 0;
for(int i = 0; i < 128; i=i+2)
{
for(int j = 0; j < 128; j++)
{
_pBuf[iu] = s_ColoredPoint( i*0.1, m_height[i*128 + j], j*0.1, m_Color );
iu++;
}
for(int j = 127; j >= 0; j--)
{
_pBuf[iu] = s_ColoredPoint( i*0.1, m_height[i*128 + j], j*0.1, m_Color );
iu++;
}
}
m_VB->Unlock();
}
Функция Compute() перебирает наши 128х128 точек и для каждой вызывает расчёт высоты. U — это, собственно, та самая страшная функция прогибов мембраны. FillBuffer копирует данные из временного буфера высот в буфер вершин. В общем, для процессора этого достаточно, можно смотреть на результаты.
Теперь самое интересное. Начинаем использовать ATi Stream.
Технологию ATi Stream можно использовать с помощью нескольких технологий. Базовой является CAL (Compute Abstract Layer). Эта библиотека низкоуровневая и подразумевает много кода. Мы будем использовать более высокоуровневую библиотеку brook+. Для этого необходимо подключить к проекту brook.h и brook.lib. Их можно найти в папке ($Program Files)\ATI Brook+ 1.4.0_beta\sdk.
Ядро работает в адресном пространстве видеокарты и не может напрямую обращаться к переменным, объявленным в оперативной памяти, и наоборот — хост не может обращаться к переменным, объявленным в ядре. Поэтому перед вызовом функции ядра необходимо скопировать используемые массивы в память видеокарты, а после выполнения ядра скопировать рассчитанные данные из памяти видеокарты в ОЗУ. Передать данные из ОЗУ в память видеокарты можно таким образом:
Stream<float> inputData(1,length);
inputData.read(inputPtr);
Здесь мы передаём в видеокарту одномерный массив длинной length.
Кроме того, ядро не может записывать в произвольный участок памяти. В нашем случае мы ничего не отправляем в видеокарту, только получаем рассчитанные данные.
Наша функция-хост выглядит так:
void ComputeCAL(float _t)
{
unsigned int length[2] = {128,128};
Stream<float> tmpHeight(2,length);
sum(a,b,c,_t,s,(float)p,tmpHeight);
if(tmpHeight.error())
pLog->SetError("косяк", 3);
else
tmpHeight.write((void*)m_height);
}
В первых двух строчках выделяется участок памяти в видеокарте. По сути, эти строчки аналогичны оператору new в C++. Length — это массив, в котором хранятся размеры измерений выделяемого массива. Его размер должен быть равен количеству измерений выделяемого массива. То есть в данном случае мы выделяем двумерный массив размера 128х128.
Функция sum() — это функция ядра, о ней позже.
Далее мы проверяем массив на наличие ошибок и с помощью функции write() копируем из памяти видеокарты в ОЗУ. Вот здесь более удобно использовать одномерный массив высот, а не двумерный.
Как видите, здесь нету циклов по точкам мембраны! Координаты точки задаются в самой функции ядра. Как это происходит расскажу позже.
Ядро
Теперь самое главное. Студия сама не скомпилит функцию ядра. Для этого в brook+ есть небольшой консольный компилятор brcc.exe. Его скопируйте в папку с исходниками проекта. Так как пользоваться консольным компилятором неудобно, советую пользоваться программой Stream KernelAnalyzer, которую можно скачать с сайта AMD. Данная программа компилирует функции ядра, выводя сообщения об ошибках, информацию о совместимости с разными видеокартами и примерную производительность на разных видеокартах. Кстати, эта программа также показывает ассемблерный код введённой функции, что очень удобно при написании приложения, использующего ATi Stream с помощью библиотеки CAL (Compute Abstract Lauer). После того как функция ядра написана с помощью KernelAnalyzer создаём в папке с исходниками проекта файлик с расширением .br и копируем в него подопытную функцию. Теперь создайте ярлык на программку brcc.exe и в свойствах на вкладке «ярлык» в строке объект после пути допишите
-o <имя генерируемых файлов> <имя исходного файла>.br
Например, я писал:
После запуска этого ярлыка в папке появится три новых файла U.h, U.cpp и U_gpu.h их надо добавить в проект.
Теперь о самой функции ядра. Вот как она выгладит:
kernel void sum( float a,
float b,
float c,
float t,
float s,
float p,
out float result<>)
{
float _U = 0.0f;
float2 vPos = indexof(result).xy;
float n;
float m;
float i;
float j;
for (m = 1.0f; m < p; m = m + 1.0f)
{
float tmp = 0.0f;
j = m*3.1415f/b;
for (n = 1.0f ;n < p; n = n + 1.0f)
{
i = n*3.1415f/a;
tmp += cos(sqrt(i*i + j*j)*c*t)*sin(n*0.31415f*vPos.x/a)*sin(n*3.1415f/2.0f);
}
_U += tmp*sin(m*0.31415f*vPos.y/b)*sin(m*3.1415f/2.0f);
}
result = _U*4.0f/(a*b*s);
}
Массив result делится на size/ N частей, где N — количество потоковых ядер в видео карте, size – количество элементов в массиве. В процессе выполнения берутся первые N элементов, для них выполняется функция ядра. Потом функция ядра выполняется для вторых N элементов. И так далее, пока не переберётся весь массив. В самой функции индексы элемента для которого выполняется функция можно узнать с помощью функции
float2 vPos = indexof(result).xy.
Обратите внимание, что мы не можем записать в произвольный элемент массива result. Поэтому возвращая результат не нужно использовать индексацию. В остальном, функция идентична функции выполняемой на процессоре. Можно запускать.
Для удобного тестирования в поток рисования можно добавить такие строчки:
if(w->CAL())
w->ComputeCAL(_time);
else
w->Compute(_time);
где w->CAL() проверяет, выбрана ли галочка расчёта на видеокарте.
Результат
На моём компьютере (Pentium DualCore 2.1GHz, Radeon HD4730) прирост получился порядка 250~300 раз.
Это не совсем точный тест, потому что я мерил только fps, но не время выполнения расчёта, и не очень честный тест потому что я не оптимизировал расчёт на процессоре, например, можно было бы использовать OpenMP. Кроме того, в данной задаче не нужно копировать в видеокарту входные данные, а выходной массив достаточно лёгкий. Всё это на реальной задаче снизит разрыв в скорости, но не на столько, чтобы не использовать мощность видеокарты. Что касается применения в играх: я считаю, что в играх видеокарты и так загружены, если у вас видеопроцессор простаивает — улучшите графику! А вот для инженерных расчётов, перекодирования видео и т.д. Stream/CUDA вещи просто незаменимые.
Пример к статье «Использование технологии ATI Stream»
Литература
1. http://ru.wikipedia.org/wiki/FLOPS
2. http://ru.wikipedia.org/wiki/Классификация_по_Флинну
3. http://ru.wikipedia.org/wiki/SSE
#ATI, #ATI Stream, #многопоточность, #С++
25 декабря 2009
(Обновление: 23 янв 2010)
Комментарии [36]