Войти
ПрограммированиеСтатьиОбщее

Использование технологии ATI Stream

Автор:

В этой статье я хочу рассказать о технологии ATi Stream — она аналогична CUDA, созданной конкурирующей компанией NVIDIA. Однако о последней в Интернете существует огромная куча статей как на английском так и на русском, в то время как по Stream ‘у я не нашёл ничего, кроме официального мануала. Итак приступим.

Введение
Что такое мультипроцессор
Что нужно
Математическая основа
Хост
Ядро
Результат

Введение

Процессоры видеокарт уже давно стали мощнее центральных процессоров. Например, самый мощный на сегодня настольный процессор Intel Core i7-975 XE 3,33 ГГц имеет пиковую производительность 47,7 Гфлопс [1], видеокарта нижней ценовой категории Radeon 4730 имеет производительность 480 Гфлопс. Несравнимо. Особенно учитывая, что это продукты из разных ценовых категорий. Так вот, чтобы использовать всю эту мощность разработчики видео-чипов создали технологии программирования видеокарт.

Что такое мультипроцессор

Существует классификация компьютеров по Флинну [2]:

Обычный процессор относится к SISD (о SSE умолчим [3]), то есть одновременно выполняет одну инструкцию над одним числом. Это дань универсальности. Видеокарты в виду своего специфичного предназначения пошли по пути SIMD. В видеокартах такая архитектура необходима, потому что цвет каждого пикселя экрана вычисляется независимо от других пикселей по одному и тому же алгоритму, а сами функции, по которым вычисляется этот цвет, лёгкие относительно общей вычислительной сложности. В результате видео-процессор — это множество ядер, которые в каждый момент времени могут выполнять только одну и ту же команду, но над разными данными. Такой процессор называется мультипроцессором.

Таким образом, чтобы успешно использовать видеокарту для вычислений нужно создать алгоритм с множеством операций независящих друг от друга и не использующий ветвления. Ветвления использовать можно, но в этом случае выполняться будут обе ветки. Сначала одна для тех данных, для которых условие истинно, потом другая для которых условие ложно. Данный процесс полностью прозрачен и не требует усилий со стороны программиста. На самом деле в видеокарте есть несколько мультипроцессоров в документации по Stream они называются “SIMD Engine”. Каждый такой мультипроцессор делится на несколько “Thread Processors” которые в свою очередь делятся на “Stream Cores”. Количество “Stream Cores” обычно пишут на коробке с видеокартой например у HD4730 их 8“SIMD Engine” * 16“Thread Processors”  * 5“Stream Cores” =  640.

Что нужно

Всегда считал, что практика лучше теории, поэтому приступим. Итак, для написания программы с использованием ATi Stream вам понадобится:

Математическая основа

Будем делать на примере моделирования колебаний прямоугольной мембраны (а то что-то все матрицы умножают, надоело — уже не интересно =)).

Такие колебания описываются дифференциальным уравнением второго порядка в частных производных:

clp48 | Использование технологии ATI Stream

где clp481 | Использование технологии ATI Stream , s – сила натяжения, m – масса.

Решением данного уравнения с начальными условиями

clp482 | Использование технологии ATI Stream

и граничными условиями

clp483 | Использование технологии ATI Stream

является функция (кому интересно - решается методом тройного интегрального преобразования : конечное Sin преобразование по координатам и Лапласа по времени):

clp484 | Использование технологии ATI Stream

где a, b – размеры пластинки
δ – дельта-функция
x’, y’ – координаты единичной сосредоточенной силы.

Ну вот, с математикой разобрались.

Хост

Та часть кода, которая выполняется на процессоре, называется хостом (host). Часть программы, выполняемая на видеокарте, называется ядром (kernel).

Создадим проект. Визуализировать нашу мембрану мы будем с помощью DirectX, поэтому подключаем его, создаём устройство, камеру – всё как обычно, на этом останавливаться не будем — статья не про то.

Для визуализации нам понадобится массив смещений мембраны. Размер выберем для него 128 на 128. Этого хватит чтобы «убить» процессор.

float m_height[128*128];

Почему я создал одномерный массив, скажу потом. Также нам понадобятся параметры мембраны:

float a; // размер мембраны по X
float b; // размер мембраны по Y
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

Например, я писал:

-o U U.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]