- •1. Конструкторський розділ
- •1.1 Технологія OpenCl
- •1.2 Специфікація відеокарти Radeon hd 4850
- •1.3 Архітектура чіпу rv770
- •1.4 Мультипроцесор
- •2. Алгоритмічний розділ
- •2.1 Постановка завдання
- •2.2 Вибирання засобів розробки програмного забезпечення
- •2.3 Алгоритм
- •2.4 Збірка програмного забезпечення
- •3. Технологічний розділ
- •3.1 Опис алгоритму
- •4. Тестувальний розділ
- •4.1 Тестування програми
- •5. Висновок
- •6. Список літератури
- •7. Додаток
3. Технологічний розділ
3.1 Опис алгоритму
Як було вище зазначено, що розробка OpenCL проекту, полягая з написанню ядeр(kernal), почнемо з написанню ядер.
Написання Kernal.
Добуток матриць. Взагалі ядра OpenCL дуже нагадують звичайну фунцію в мові С, а бо її розширенню С++. Фрагмент коду 3.1. Kernal вказує що це саме ядро OpenCL, далі слідує тип, і назва функції, після того відкриваються дужки і перечислюємо вхідні параметри. Далі потрібно підсумувати суму по рядкам і по стовбцям( i-рядок * на j-стовбець, потім сумуємо). Змінна і, j індетифікатор global вказує, що читати і записувати данні будимо в глобальну пам'ять. Також є перевірка, щоб не війти за значення масивів. Цикл саме для сумування. Для реалізації алгоритму перемноження матриць, потрібно 3 цикли, чому тут 1?тому що int i = get_global_id (1); це вже цикл мовою OpenCl. Тому потрібно лише 1 для підрахунку сум. А далі записуємо результат з нашої суми, в масив.
Фрагмент коду 3.1. Добуток матриць.
1. __kernel void matrix_multiply (
unsigned AH , unsigned AW , __global float * AE ,
unsigned BH , unsigned BW , __global float * BE ,
unsigned RH , unsigned RW , __global float * RE, int iNumElements)
{
2. float sum = 0;
3. int i = get_global_id (1);
4. int j = get_global_id (0);
5. int k;
6. if ((i>= iNumElements)&&( j>= iNumElements))
{
7. return;
}
8. for (k = 0; k < AW; k += 1)
{
9. sum += AE[i * AW + k] * BE[k * BW + j];
}
10. RE[i * RW + j] = sum ;
}
Віднімання матриць. Фрагмент коду 3.2. Все майже так само як і в добутку матриць, але зменшилась кількість параметрів, тай циклів зменшилось, потрібно всього два, і суму не потрібно рахувати. Як читати данні так і записувати будемо з глобально пам’яті.
Фрагмент коду 3.2. Віднімання матриць.
1. __kernel void matrix_Sub (__global float * AE , __global float * BE ,
__global float * RE, int iNumElements)
{
2. int i = get_global_id (1);
3. int j = get_global_id (0);
4. if ((i>= iNumElements)&&( j>= iNumElements))
{
5. return;
}
6. RE[i * j] = AE[i *j] - BE[i *j];
}
Сума матриць. Фрагмент коду 3.3. Змінилась назва, і тепер не віднімаємо, а додаємо.
Фрагмент коду 3.3. Додавання матриць.
1. __kernel void matrix_ADD (__global float * AE , __global float * BE ,
__global float * RE, int iNumElements)
{
2. int i = get_global_id (1);
3. int j = get_global_id (0);
4. if ((i>= iNumElements)&&( j>= iNumElements))
{
5. return;
}
6. RE[i * j] = AE[i *j] + BE[i *j];
}
Додавання векторів. Фрагмент коду 3.4. Передача параметрів як покажчики. Перевірка щоб не вийти за межі масиву. Формування одного циклу, і запис суми в інший вектор.
Фрагмент коду 3.4. Додавання векторів.
1.__kernel void VectorAdd(__global const float* a, __global const float* b, __global float* c, int iNumElements)
{
2. int iGID = get_global_id(0);
3. if (iGID >= iNumElements)
{
4. return;
}
5. c[iGID] = a[iGID] + b[iGID];
}
Віднімання векторів. Фрагмент коду 3.5. Передача параметрів як покажчики. Перевірка щоб не вийти за межі масиву. Формування одного циклу, і запис різниці в інший вектор.
Фрагмент коду 3.5. Віднімання двох векторів.
1.__kernel void VectorSub(__global const float* a, __global const float* b, __global float* c, int iNumElements)
{
2. int iGID = get_global_id(0);
3. if (iGID >= iNumElements)
{
4. return;
}
5. c[iGID] = a[iGID] - b[iGID];
}
Множення вектора на скаляр. Фрагмент коду 3.6. При множенні вектора на скаляр, отримується такий самий вектор. Передача параметрів. Перевірка щоб не вийти за межі масиву. Ну і саме перемноження.
Фрагмент коду 3.6. Вектор на скаляр.
1. __kernel void VectorMulSc(__global const float* a, __global const float* b, __global float* c, int iNumElements)
{
2. int iGID = get_global_id(0);
3. if (iGID >= iNumElements)
{
4. return;
}
5. c[iGID] = a[iGID] * b[0];
}
Добуток векторів. Фрагмент коду 3.7. При добутку двох в результаті отримуємо число. Тобто потрібно просумувати по циклу. Що й показано на прикладі.
Фрагмент коду 3.7. Добуток векторів.
1. __kernel void VectorSub(__global const float* a, __global const float* b, int iNumElements)
{
2. int iGID = get_global_id(0);
3. int Sum=0;
4. if (iGID >= iNumElements)
{
5. return;
}
6. Sum += a[iGID] * b[iGID];
}
По-перше, в тому, як добувається індекс нитки? При запуску ниток , OpenCL створює індексний простір, який повинен бути звично 1-но, 2-ух або 3-ох мірним. У цьому просторі кожна нитка має свій унікальний глобальний індекс, i-ту компоненту якого і повертає виклик get_global_id (i). Цей глобальний індексний простір може бути розбитий на локальні блоки, і нитки можуть визначати свої індекси і всередині цих блоків, але глобальна нумерація залишається при цьому попередньою. І тут, мабуть, варто підкреслити: в OpenCL глобальний індексний простір розбивається на блоки, на відміну від CUDA, та Ati Stream, де глобальний простір складається як сітка, з локальних блоків, тобто, в OpenCL первинно глобальний простір, а в CUDA та Ati Stream локальні блоки.
По-друге, при описі ядер на OpenCL C програміст повинен вказувати область дії показників. OpenCL подібно CUDA та Ati Stream передбачає наявність різної пам'яті на прискорювачі. Вважається, що є глобальна, більш повільна пам'ять, доступна всім ниткам, і доступна для звернень (читання і запису блоків даних) з боку основної системи. І, що є локальна, швидка пам'ять, доступна ниткам, зібраними в одну локальну групу. Відповідно, модифікатори __global і __local. Використовують перший, щоб вказати те, що читати матриці і записувати результати обчислення потрібно в глобальну пам'ять прискорювача.
По-третє, власне сам процес запуску обчислень. Логічні кроки, які потрібно виконати для цього запуску, такі ж, які Brook + і CUDA. Але технічно, через те, що OpenCL є тільки бібліотекою і ніяк не розширює основну мову програмування якимись новими базовими типами і синтаксичними конструкціями, для запуску ниток потрібно описати більше операцій і деталей, які в Ati Stream або C for CUDA формуються і уточнюються відповідними компіляторами автоматично.
Кожне звернення до функції OpenCL потенційно може повернути код помилки, і помилки ці в реальних програмах слід обробляти.
Написання HOST.
1. Host написаний на мові С++. Робота з прискорювачем починається зі створення контексту для роботи з пристроями(фрагмент коду 3.8. - це перший пристрій) черги команд (рядки 1-6 в Фрагменті коду 3.8.).
Фрагмент коду 3.8. Ініціалізація пристрою.
1. cntx = clCreateContextFromType (NULL , CL_DEVICE_TYPE_GPU , NULL , NULL , NULL );
2. clGetContextInfo (cntx , CL_CONTEXT_DEVICES , 0, NULL , &cb );
3. ds = malloc (cb );
4. clGetContextInfo (cntx , CL_CONTEXT_DEVICES , cb , ds , NULL );
5. cq = clCreateCommandQueue (cntx , ds [0] , 0, NULL );
6. free (ds );
фрагмент коду 3.8. контекст створюється для GPU, що в термінах OpenCL означає пристрої, які окрім проведення обчислень здатні забезпечувати роботу з графікою через інтерфейси OpenGL або DirectX. Контекст можна створювати і для інших пристроїв, наприклад, для центральних процесорів (CL_DEVIC_TYPE_CPU) або прискорювачів на основі процесорів Cell (CL_DEVICE_TYPE_ACCELERATOR), або взагалі для всіх OpenCL пристроїв в системі (CL_DEVICE_TYPE_ALL). Як вже було зазначено, OpenCL намагаються зробити універсальним інструментом.
Контекст визначає те, в які бінарні коди потрібно транслювати вихідний текст ядра, і на яких пристроях запускати нитки, ці коди виконують і інші особливості.
Самий запуск груп ниток, що виконують ядра, або операції запису / читання даних в пам'ять / з пам`яті прискорювачів задається чергою спеціальних команд, виконуваних не обов'язково в послідовному порядку. Частково порядок виконання цих команд, можна змінити за допомогою подій (events), операції з якими так само визначає OpenCL. Власне ось: черга команд (command queue) тут створюється для того, щоб можна було запустити через неї обчислення. Вона прив'язується до одного (першому серед доступних) пристрою.
2. Далі (рядки1-3 в фрагменті коду 3.9.) здійснюється компіляція програми і створення з неї ядра.
Фрагмент коду 3.9. Компіляція програми.
1. p = clCreateProgramWithSource (cntx , 1, & program_source , NULL , NULL );
2. clBuildProgram (p, 0, NULL , NULL , NULL , NULL );
3. k = clCreateKernel (p, " matrix_multiply ", NULL );
Вважається, що вихідний текст програми «ядра», послідовність байтів, завантажених в пам'ять і на них вказуює program_source. Трансляція здійснюється в створеному раніше контексті, тобто, для пристроїв класу GPU. Програму можна формувати не з одного джерела, а з декількох (в розглянутому фрагменті коду 3.9. явно вказано як використовувати один текст у другому аргументі виклику clCreateProgramWithSource). Після компіляції програми «бінарні» модулі можна отримати за допомогою спеціальної функції OpenCL.
3. Наступними діями (рядки 1-9 в фрагмент коду 3.10.) виділяється пам'ять на прискорювачі.
Фрагмент коду 3.10. Виділяється пам'ять на прискорювачі.
1. A.H = L; A.W = M;
2. A.E = clCreateBuffer (cntx , CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR ,
3. A.H * A.W * sizeof ( cl_float ), mem_A , NULL );
4. B.H = M; B.W = N;
5. B.E = clCreateBuffer (cntx , CL_MEM_READ_ONLY | CL_MEM_COPY_HOST_PTR ,
6. B.H * B.W * sizeof ( cl_float ), mem_B , NULL );
7. R.H = L; R.W = N;
8. R.E = clCreateBuffer (cntx , CL_MEM_READ_WRITE ,
9. R.H * R.W * sizeof ( cl_float ), NULL , NULL );
Одночасно з цим вона заповнюється вихідними даними для матриць A і B. У OpenCL це можна зробити за один виклик, що є зручно.
4. Потім йде блок коду (рядки 1-9 в фрагменті коду 3.10.), який встановлює аргументи ядра.
Фрагмент коду 3.10. Встановлення аргументів для ядра.
1. clSetKernelArg (k, i += 1, sizeof ( unsigned ), &A.H);
2. clSetKernelArg (k, i += 1, sizeof ( unsigned ), &A.W);
3. clSetKernelArg (k, i += 1, sizeof ( cl_mem ), &A.E);
4. clSetKernelArg (k, i += 1, sizeof ( unsigned ), &B.H);
5. clSetKernelArg (k, i += 1, sizeof ( unsigned ), &B.W);
6. clSetKernelArg (k, i += 1, sizeof ( cl_mem ), &B.E);
7. clSetKernelArg (k, i += 1, sizeof ( unsigned ), &R.H);
8. clSetKernelArg (k, i += 1, sizeof ( unsigned ), &R.W);
9. clSetKernelArg (k, i += 1, sizeof ( cl_mem ), &R.E);
Тут ще раз можна повторити зауваження про те, що область пам'яті в прискорювачі описуються значеннями типу cl_mem. Коли система виконання OpenCL виявляє, що деякий аргумент ядра є покажчиком, то вона очікує передачі значення типу cl_mem, щоб правильно сформувати цей покажчик.
Відповідно, теоретично, іншим способом передати інформацію про область пам'яті в ядро OpenCL не можна.
При цьому, аргументи ядра передаються дуже просто - через копіювання зазначеного обсягу (третій аргумент clSetKernelArg) даних, розміщених за вказаною адресою (аргумент 4). після копіювання даних у clSetKernelArg, пам'ять можна знову використовувати.
5. Нарешті (рядки 1-4 в фрагменті коду 3.11.) запуск обчислення і отримання його результату.
Фрагмент коду 3.11. Отримання результату.
1. clEnqueueNDRangeKernel (cq , k, 2, NULL ,
2. global_work_size , local_work_size , 0, NULL , NULL );
3. clEnqueueReadBuffer (cq , R.E, CL_TRUE , 0,
4. R.W * R.H * sizeof ( cl_float ), mem_R , 0, NULL , NULL );
Здійснюється це через запис команд у чергу. Перша команда, NDRangeKernel (ND - N-Dimension), є командою запуску ядра. У ній вказується розмірність і розміри глобального простору індексів (2, global_work_size - Двовимірна область розмірами L × N), для кожного з яких буде запущена нитка, розміри локальних груп ниток (local_work_size - 16 × 16).
Після постановки обчислення в чергу управління повертається в основну програму, і саме обчислення відбувається відносно цього основного потоку керування асинхронно.
Наступна команда, яка поміщається в чергу - це ReadBuffer. Сенс її зрозумілий, а уваги заслуговує третій аргумент виклику: CL_TRUE. Він говорить про те, що OpenCL повинен дочекатися виконання цієї операції читання перед тим, як повернути управління в основну програму.
При цьому, обидві команди, що записані в чергу, записуються туди без будь-якої інформації про події (останні три параметри у виклику по нулях: 0, NULL, NULL). Для OpenCL це означає те, що виконувати ці команди потрібно в тому порядку, в якому вони були поміщені в чергу. Тобто, ReadBuffer виконається після NDRangeKernel, після чого управління буде повернуто в основну програму, в якій можна буде використовувати дані з mem_R. Що й потрібно нам.
Опишемо тепер клас «Операції з векторами» .
Фрагмент коду 3.12. Змінні класу.
1. class VectorGPU {
2. private:
3. cl_context cxGPUContext;
4. cl_command_queue cqCommandQueue;
5. cl_platform_id cpPlatform;
6. cl_device_id cdDevice;
6. cl_program cpProgram;
7. cl_kernel ckKernel;
8. cl_mem cmDevSrcA;
9. cl_mem cmDevSrcB;
10. cl_mem cmDevDst;
В першій строчці об`являємо клас VectorGPU. Рядки 3-10, йде ініціалізація пристроя, платформи, ядра, тобто поля класу.
Фрагмент коду 3.13. Змінні класу.
1. size_t szGlobalWorkSize;
2. size_t szLocalWorkSize;
3. size_t szParmDataBytes;
4. size_t szKernelLength;
5. cl_int ciErr1, ciErr2;
6. char* cPathAndName;
7. char* cSourceCL;
Ініціалізується пам'ять яку будемо використовувати(1-7). Теж поля класу.
Фрагмент коду 3.14. Об`явлення методів.
1. public:
2. int n;
3. void *srcA, *srcB, *dst;
4. VectorGPU();
5. VectorGPU(int size);
6. ~VectorGPU();
7. int ClInit();
8. int CLAllocMem (int iNumElements);
9. int ClCreateProg(char *funcname, int iNumElements);
10. int ClWriteDataGPU();
11. int ClCompute();
12. int ClReadDataGPU();
13. unsigned int VAdd();
14. unsigned int VSub();
15. unsigned int MulSc(float scalar);
};
Методи, які будемо використовуються в даній програмі: додавання векторів, множення , віднімання, методи запису в пам'ять, методи читання с пам’яті, і таке інше.
Метод додавання векторів.
Фрагмент коду 3.15. Реалізація методу додавання.
1. unsigned int VectorGPU::VAdd()
{
2. unsigned int t1,t2;
3. if (ClCreateProg("VectorAdd",n)==1)
{
4. t1=GetTickCount();
5. if (ClWriteDataGPU()==1)
{
6. if (ClCompute()==1)
{
7. t2=GetTickCount();
}
8. if (ClReadDataGPU()==1)/
{
}
}
}
9. return ((int)(t2-t1));
}
В фрагменті коду 3.15. Описується метод додавання. Об`ява змінних які будуть повідомлювати, що операція додавання векторів завершилась.
Метод множення на скаляр.
Фрагмент коду 3.16. Метод множення на скаляр.
1. unsigned int VectorGPU::MulSc(float scalar)
{
2. unsigned int t1,t2;
3. float *b=(float*) srcB;
4. b[0]=scalar;
5. if (ClCreateProg("VectorMulSc",n)==1)
{
6. t1=GetTickCount();
7. if (ClWriteDataGPU()==1)
{
8. if (ClCompute()==1)
{
9. t2=GetTickCount();
}
10. if (ClReadDataGPU()==1)
{
}
}
}
11. return ((int)(t2-t1));
}
Фрагмент коду 3.16. реалізація множення вектора на скаляр. Отримуємо вхідний параметр наший скаляр, і передаємо його в наше ядро. В кінці нам буде показано час виконання операції.