Увімкнення GPU прискорення. Обчислення на GPU Прорахунок з gpu на cpu

💖 Подобається?Поділися з друзями посиланням

Використання GPU для обчислень за допомогою C++ AMP

Досі в обговоренні прийомів паралельного програмування ми розглядали лише ядра процесора. Ми придбали деякі навички розпаралелювання програм з кількох процесорів, синхронізації доступу до спільно використовуваних ресурсів та використання високошвидкісних примітивів синхронізації без застосування блокувань.

Однак, існує ще один спосіб розпаралелювання програм - графічні процесори (GPU), що володіють більшим числомядер, ніж високопродуктивні процесори. Ядра графічних процесорів чудово підходять для реалізації паралельних алгоритмів обробки даних, а велика їхня кількість з лишком окупає незручності виконання програм на них. У цій статті ми познайомимося з одним із способів виконання програм на графічному процесорі з використанням комплекту розширень мови C++ під назвою C++ AMP.

Розширення C++ AMP засновані мовою C++ і саме тому у цій статті демонструватимуться приклади мовою C++. Однак, при помірному використанні механізму взаємодій. NET, ви зможете використовувати алгоритми C++ AMP у своїх програмах для .NET. Але про це ми поговоримо наприкінці статті.

Введення в C++ AMP

По суті, графічний процесорє таким самим процесором, як будь-які інші, але з особливим набором інструкцій, великою кількістю ядер і своїм протоколом доступу до пам'яті. Однак між сучасними графічними та звичайними процесорами існують великі відмінності, та їх розуміння є запорукою створення програм, що ефективно використовують обчислювальні потужності графічного процесора.

    Сучасні графічні процесори мають дуже маленький набір інструкцій. Це передбачає деякі обмеження: відсутність можливості виклику функцій, обмежений набір типів даних, що підтримуються, відсутність бібліотечних функцій та інші. Деякі операції, такі як умовні переходи можуть коштувати значно дорожче, ніж аналогічні операції, що виконуються на звичайних процесорах. Очевидно, що перенесення великих обсягів коду з процесора на графічний процесор за таких умов потребує значних зусиль.

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

    Підтримка синхронізації між ядрами графічного процесора, що виконують одне завдання, дуже мізерна, і повністю відсутня між ядрами графічного процесора, що виконують різні завдання. Ця обставина вимагає синхронізації графічного процесора зі звичайним процесором.

Відразу постає питання, які завдання підходять для вирішення на графічному процесорі? Майте на увазі, що не всякий алгоритм підходить для виконання на графічному процесорі. Наприклад, графічні процесори не мають доступу до пристроїв вводу/виводу, тому у вас не вдасться підвищити продуктивність програми, яка отримує стрічки RSS з інтернету, за рахунок використання графічного процесора. Однак на графічний процесор можна перенести багато обчислювальних алгоритмів і забезпечити масове їх розпаралелювання. Нижче наводиться кілька прикладів таких алгоритмів (цей список далеко не повний):

    збільшення та зменшення різкості зображень та інші перетворення;

    швидке перетворення Фур'є;

    транспонування та множення матриць;

    сортування чисел;

    інверсія хеша "в лоб".

Відмінним джерелом додаткових прикладів може бути блог Microsoft Native Concurrency, де наводяться фрагменти коду та пояснення до них для різних алгоритмів, реалізованих на C++ AMP.

C++ AMP - це фреймворк, що входить до складу Visual Studio 2012, що дає розробникам на C++ простий спосіб виконання обчислень на графічному процесорі і вимагає лише наявності драйвера DirectX 11. Корпорація Microsoft випустила C++ AMP як відкриту специфікацію, яку може реалізувати будь-який виробник комп'ютерів.

Фреймворк C++ AMP дозволяє виконувати код на графічних прискорювачів(accelerators), що є обчислювальними пристроями. За допомогою драйвера DirectX 11 фреймворк C++ AMP динамічно виявляє усі прискорювачі. До складу C++ AMP входять також програмний емуляторприскорювача та емулятор на базі звичайного процесора, WARP, які служить запасним варіантом у системах без графічного процесора або з графічним процесором, але без драйвера DirectX 11, і використовує кілька ядер та інструкції SIMD.

А тепер приступимо до дослідження алгоритму, який можна розпаралелити для виконання на графічному процесорі. Реалізація нижче приймає два вектори однакової довжини та обчислює поточковий результат. Важко уявити щось більш прямолінійне:

Void VectorAddExpPointwise(float* first, float* second, float* result, int length) ( for (int i = 0; i< length; ++i) { result[i] = first[i] + exp(second[i]); } }

Щоб розпаралелити цей алгоритм на звичайному процесорі, потрібно розбити діапазон ітерацій на кілька піддіапазонів і запустити по одному потоку виконання для кожного з них. Ми присвятили досить багато часу в попередніх статтях саме такому способу розпаралелювання нашого першого прикладу пошуку простих чисел - ми бачили, як це можна зробити, створюючи потоки вручну, передаючи завдання пулу потоків і використовуючи Parallel.For і PLINQ для автоматичного розпаралелювання. Згадайте також, що при розпаралелювання схожих алгоритмів на звичайному процесорі ми особливо дбали, щоб не роздробити завдання на дрібні завдання.

Для графічного процесора ці попередження непотрібні. Графічні процесори мають безліч ядер, що виконують потоки дуже швидко, а вартість перемикання контексту значно нижча, ніж у звичайних процесорах. Нижче наводиться фрагмент, який намагається використовувати функцію parallel_for_eachіз фреймворку C++ AMP:

#include #include using namespace concurrency; void VectorAddExpPointwise(float* first, float* second, float* result, int length) ( array_view avFirst (length, first); array_view avSecond(length, second);<1>array_view

avResult(length, result);

avResult.discard_data(); parallel_for_each(avResult.extent, [=](index. Клас array_view служить для обгортання даних, які передаються графічному процесору (прискорювачу). Його шаблонний параметр визначає тип даних та їх розмірність. Щоб виконати на графічному процесорі інструкції, що звертаються до даних, спочатку оброблюваним на звичайному процесорі, хтось чи щось має подбати про копіювання даних у графічний процесор, оскільки більшість сучасних графічних карток є окремими пристроями з власною пам'яттю. Це завдання вирішують екземпляри array_view – вони забезпечують копіювання даних на вимогу і тільки коли вони дійсно необхідні.

Коли графічний процесор виконає завдання, копіюються дані назад. Створюючи екземпляри array_view з аргументом типу const, ми гарантуємо, що first і second будуть скопійовані на згадку про графічний процесор, але не копіюватимуться назад. Аналогічно, викликаючи discard_data(), ми виключаємо копіювання результату з пам'яті звичайного процесора в пам'ять прискорювача, але ці дані копіюватимуться у зворотному напрямку.

Функція parallel_for_each приймає об'єкт extent, що визначає форму даних і функцію для застосування до кожного елемента в об'єкті extent. У цьому прикладі ми використовували лямбда-функцію, підтримка яких з'явилася в стандарті ISO C++2011 (C++11). Ключове слово restrict (amp) доручає компілятор перевірити можливість виконання тіла функції на графічному процесорі і відключає більшу частину синтаксису C++, який не може бути скомпільований в інструкції графічного процесора.

Параметр лямбда-функції, index<1>об'єкта представляє одномірний індекс. Він повинен відповідати об'єкту extent, що використовується - якби ми оголосили об'єкт extent двовимірним (наприклад, визначивши форму вихідних даних у вигляді двовимірної матриці), індекс також повинен був би бути двомірним. Приклад такої ситуації наводиться трохи нижче.

Зрештою, виклик методу synchronize()Наприкінці методу VectorAddExpPointwise гарантує копіювання результатів обчислень з array_view avResult, вироблених графічним процесором, назад у масив результату.

На цьому ми закінчуємо наше перше знайомство зі світом C++ AMP, і тепер ми готові до докладніших досліджень, а також до більш цікавих прикладів, що демонструють вигоди від використання паралельних обчислень на графічному процесорі. Складання векторів - не найкращий алгоритм і не найкращий кандидат для демонстрації використання графічного процесора через великі накладні витрати на копіювання даних. У наступному підрозділі будуть показані два цікавіші приклади.

Розмноження матриць

Перший «справжній» приклад, який ми розглянемо, – множення матриць. Для реалізації ми візьмемо простий кубічний алгоритм множення матриць, а чи не алгоритм Штрассена, має час виконання, близьке до кубічного ~O(n 2.807). Для двох матриць: матриці A розміром m x w та матриці B розміром w x n, наступна програма виконає їх множення та поверне результат - матрицю C розміром m x n:

Void MatrixMultiply(int * A, int m, int w, int * B, int n, int * C) ( for (int i = 0; i< m; ++i) { for (int j = 0; j < n; ++j) { int sum = 0; for (int k = 0; k < w; ++k) { sum += A * B; } C = sum; } } }

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

Void MatrixMultiply (int * A, int m, int w, int * B, int n, int * C) ( array_view avA(m, w, A); array_view avB(w, n, B);<2>array_view< w; ++k) { sum + = avA(idx*w, k) * avB(k*w, idx); } avC = sum; }); }

avC(m, n, C);

avC.discard_data();

Приклади розв'язання задач на графічному процесорі, представлені вище, мають дуже просту реалізацію внутрішнього циклу. Зрозуміло, що так не завжди буде. У блозі Native Concurrency, посилання на який вже наводилося вище, показується приклад моделювання гравітаційних взаємодій між частинками. Моделювання включає нескінченну кількість кроків; на кожному кроці обчислюються нові значення елементів вектора прискорень кожної частки і потім визначаються їх нові координати. Тут розпаралелювання піддається вектор частинок - при досить велику кількість частинок (від кількох тисяч і вище) можна створити досить велику кількість завдань, щоб завантажити роботою всі ядра графічного процесора.

Основу алгоритму становить реалізація визначення результату взаємодій між двома частинками, як показано нижче, яку легко можна перенести на графічний процесор:

// тут float4 - це вектори з чотирма елементами, // репрезентують частинки, що беруть участь в операціях . використовується float absDist = dist.x*dist.x + dist.y*dist.y + dist.z*dist.z; float invDist = 1.0f = dist*PARTICLE_MASS*invDistCube;

Вихідними даними на кожному кроці моделювання є масив з координатами та швидкостями руху частинок, а в результаті обчислень створюється новий масив з координатами та швидкостями частинок:

Struct particle ( float4 position, velocity; // реалізації конструктора, конструктора копіювання та // оператора = з restrict (amp) опущені для економії місця); void simulation_step (array & previous, array & next, int bodies) (extent<1>ext(bodies);<1>parallel_for_each (ext, [&](index< bodies; ++body) { bodybody_interaction (acceleration, p.position, previous.position); } p.velocity + = acceleration*DELTA_TIME; p.position + = p.velocity*DELTA_TIME; next = p; }); }

Із залученням відповідного графічного інтерфейсу, моделювання може бути дуже цікавим. Повний приклад, представлений командою розробників C++ AMP, можна знайти у блозі Native Concurrency. На моїй системі з процесором Intel Core i7 та відеокартою Geforce GT 740M, моделювання руху 10 000 частинок виконується зі швидкістю ~2.5 кадру в секунду (кроків в секунду) з використанням послідовної версії, що виконується на звичайному процесорі, та 160 кадрів в секунду з використанням оптимізований версії, що виконується на графічному процесорі – величезне збільшення продуктивності.

Перш ніж завершити цей розділ, необхідно розповісти ще одну важливу особливість фреймворку C++ AMP, яка може ще більше підвищити продуктивність коду, що виконується на графічному процесорі. Графічні процесори підтримують програмований кеш даних(часто званий пам'яттю, що розділяється (shared memory)). Значення, що зберігаються в цьому кеші, спільно використовуються всіма потоками виконання однієї мозаїці (tile). Завдяки мозаїчній організації пам'яті, програми на основі фреймворку C++ AMP можуть читати дані з пам'яті графічної карти в пам'ять мозаїки, що розділяється, і потім звертатися до них з декількох потоків виконання без повторного вилучення цих даних з пам'яті графічної карти. Доступ до пам'яті мозаїки виконується приблизно в 10 разів швидше, ніж до пам'яті графічної карти. Іншими словами, у вас є причини читання.

Щоб забезпечити виконання мозаїчної версії паралельного циклу методу parallel_for_each передається домен tiled_extent, який ділить багатовимірний об'єкт extent на багатовимірні фрагменти мозаїки, і лямбда-параметр tiled_index, що визначає глобальний та локальний ідентифікатор потоку всередині мозаїки. Наприклад, матрицю 16x16 можна розділити на фрагменти мозаїки розміром 2x2 (як показано на малюнку нижче) і потім передати функції parallel_for_each:

Extent<2>matrix(16,16); tiled_extent<2,2>tiledMatrix = matrix.tile<2,2>(); parallel_for_each (tiledMatrix, [=](tiled_index<2,2>idx) restrict (amp) ( // ...));

Кожен із чотирьох потоків виконання, що належать до однієї і тієї ж мозаїки, можуть спільно використовувати дані, що зберігаються в блоці.

При виконанні операцій з матрицями, в ядрі графічного процесора, замість стандартного індексу index<2>, як у прикладах вище, можна використовувати idx.global. Грамотне використання локальної мозаїчної пам'яті та локальних індексів може забезпечити суттєвий приріст продуктивності. Щоб оголосити мозаїчну пам'ять, поділювану всіма потоками виконання однієї мозаїці, локальні змінні можна оголосити зі специфікатором tile_static.

На практиці часто використовується прийом оголошення пам'яті, що розділяється, і ініціалізації окремих її блоків у різних потоках виконання:

Parallel_for_each(tiledMatrix, [=](tiled_index<2,2>idx) restrict(amp) ( // 32 байти спільно використовуються всіма потоками в блоці tile_static int local; // присвоїти значення елементу для цього потоку виконання local = 42; ));

Очевидно, що будь-які вигоди від використання пам'яті, що розділяється, можна отримати тільки в разі синхронізації доступу до цієї пам'яті; тобто потоки не повинні звертатися до пам'яті, доки вона не буде ініціалізована одним із них. Синхронізація потоків у мозаїці виконується за допомогою об'єктів tile_barrier(що нагадує клас Barrier з бібліотеки TPL) - вони зможуть продовжити виконання лише після виклику методу tile_barrier.Wait(), який поверне керування лише коли всі потоки викличуть tile_barrier.Wait. Наприклад:

Parallel_for_each (tiledMatrix, (tiled_index<2,2>idx) restrict(amp) ( // 32 байти спільно використовуються всіма потоками в блоці tile_static int local; // присвоїти значення елементу для цього потоку виконання local = 42; // idx.barrier - екземпляр tile_barrier idx.barrier.wait(); //Тепер цей потік може звертатися до масиву "local", //використовуючи індекси інших потоків виконання!));

Тепер саме час втілити отримані знання у конкретний приклад. Повернемося до реалізації множення матриць, виконаної без застосування мозаїчної організації пам'яті, і додамо до нього оптимізацію, що описується. Припустимо, що розмір матриці кратний числу 256 - це дозволить нам працювати з блоками 16 х 16. Природа матриць допускає можливість побічного їх множення, і ми можемо скористатися цією особливістю (фактично, розподіл матриць на блоки є типовою оптимізацією алгоритму множення матриць, що забезпечує більш ефективне використання кешу процесора).

Суть цього прийому зводиться до наступного. Щоб знайти C i,j (елемент у рядку i і в стовпці j в матриці результату), потрібно обчислити скалярний добуток між A i, * (i-й рядок першої матриці) і B *, j (j-й стовпець у другій матриці ). Однак це еквівалентно обчисленню часткових скалярних творів рядка і стовпця з подальшим підсумовуванням результатів. Ми можемо використовувати цю обставину для перетворення алгоритму множення матриць на мозаїчну версію:

Void MatrixMultiply(int * A, int m, int w, int * B, int n, int * C) ( array_view avA(m, w, A); array_view avC(m, n, C);<16,16>avC.discard_data();<16,16>parallel_for_each (avC.extent.tile

(), [=](tiled_index

idx) restrict(amp) ( int sum = 0; int localRow = idx.local, localCol = idx.local; for (int k = 0; k

Суть оптимізації, що описується в тому, що кожен потік в мозаїці (для блоку 16 х 16 створюється 256 потоків) ініціалізує свій елемент в 16 х 16 локальних копіях фрагментів вихідних матриць A і B. Кожному потоку в мозаїці потрібно тільки один рядок і один стовпець з цих блоків, але всі потоки разом будуть звертатися до кожного рядка і кожного стовпця по 16 разів. Такий підхід суттєво знижує кількість звернень до основної пам'яті.

Перш ніж закінчити обговорення фреймворку C++ AMP, нам хотілося б згадати інструменти (в Visual Studio), які є у розпорядженні розробників. Visual Studio 2012 пропонує налагоджувач для графічного процесора (GPU), що дозволяє встановлювати контрольні точки, досліджувати стек викликів, читати та змінювати значення локальних змінних (деякі прискорювачі підтримують налагодження для GPU безпосередньо; для інших Visual Studio використовує програмний симулятор), та профільник, що дає можливість оцінювати вигоди, одержувані додатком від розпаралелювання операцій із застосуванням графічного процесора. За додатковою інформацією щодо можливостей налагодження у Visual Studio звертайтеся до статті «Покроковий посібник. Налагодження програми «C++ AMP» на сайті MSDN.

Альтернативи обчислень на графічному процесорі В.NET

До цих пір у цій статті демонструвалися приклади тільки мовою C++, проте є кілька способів використовувати потужність графічного процесора в керованих додатках. Один із способів – використовувати інструменти взаємодій, що дозволяють перекласти роботу з ядрами графічного процесора на низькорівневі компоненти C++. Це рішення відмінно підходить для тих, хто бажає використовувати фреймворк C++ AMP або може використовувати вже готові компоненти C++ AMP в керованих додатках.

Інший спосіб - використовувати бібліотеку, що безпосередньо працює з графічним процесором з керованого коду. Нині є кілька таких бібліотек. Наприклад, GPU.NET та CUDAfy.NET (обидві є комерційними пропозиціями). Нижче наведено приклад з репозиторію GPU.NET GitHub, що демонструє реалізацію скалярного твору двох векторів:

Public static void MultiplyAddGpu(double a, double b, double c) (int ThreadId = BlockDimension.X * BlockIndex.X + ThreadIndex.X; int TotalThreads = BlockDimension.X * GridDimension.X; for (int ElementIdx = ThreadId

Я дотримуюся думки, що набагато простіше та ефективніше освоїти розширення мови (на основі C++ AMP), ніж намагатися організовувати взаємодії на рівні бібліотек або вносити суттєві зміни до мови IL.

Отже, після того, як ми розглянули можливості паралельного програмування в .NET і використанням GPU, напевно, ні в кого не залишилося сумнівів, що організація паралельних обчислень є важливим способом підвищення продуктивності. У багатьох серверах і робочих станціях по всьому світу залишаються безцінні обчислювальні потужності звичайних і графічних процесорів, що не використовуються, тому що додатки просто не задіють їх.

Бібліотека Task Parallel Library дає нам унікальну можливість включити в роботу всі наявні ядра центрального процесора, хоча при цьому доведеться вирішувати деякі цікаві проблеми синхронізації, надмірного дроблення завдань і нерівного розподілу роботи між потоками виконання.

Фреймворк C++ AMP та інші багатоцільові бібліотеки організації паралельних обчислень на графічному процесорі успішно можна використовувати для розпаралелювання обчислень між сотнями ядер графічного процесора. Нарешті, є, недосліджена раніше, можливість отримати приріст продуктивності від застосування хмарних технологій розподілених обчислень, які останнім часом перетворилися на один з основних напрямів розвитку інформаційних технологій.

Особливості архітектури AMD/ATI Radeon

Це схоже на народження нових біологічних видів, коли при освоєнні сфер існування живі істоти еволюціонують для поліпшення пристосованості до середовища. Так і GPU, почавши з прискорення розтеризації та текстурування трикутників, розвинули додаткові здібності по виконанню шейдерних програм для розмальовки цих трикутників. І ці здібності виявилися потрібними і в неграфічних обчисленнях, де в ряді випадків дають значний виграш у продуктивності в порівнянні з традиційними рішеннями.

Проводимо аналогії далі – після довгої еволюції на суші ссавці проникли в море, де потіснили звичайних морських мешканців. У конкурентній боротьбі ссавці використовували як нові просунуті здібності, що з'явилися на земній поверхні, так і спеціально придбані для адаптації до життя у воді. Так само GPU, ґрунтуючись на перевагах архітектури для 3D-графіки, все більше і більше мають спеціальні функціональні можливості, корисні для виконання далеких від графіки завдань.

Отже, що дозволяє GPU претендувати на власний сектор у сфері програм загального призначення? Мікроархітектура GPU побудована зовсім інакше, ніж у стандартних CPU, і в ній спочатку закладені певні переваги. Завдання графіки передбачають незалежну паралельну обробку даних, і GPU спочатку мультипоточний. Але ця паралельність йому лише на радість. Мікроархітектура спроектована так, щоб експлуатувати наявну велику кількість ниток, що вимагають виконання.

GPU складається з кількох десятків (30 для Nvidia GT200, 20 – для Evergreen, 16 – для Fermi) процесорних ядер, які в термінології Nvidia називаються Streaming Multiprocessor, а в термінології ATI – SIMD Engine. У рамках цієї статті ми будемо називати їх мініпроцесорами, тому що вони виконують кілька сотень програмних ниток і вміють майже все те саме, що і звичайний CPU, але все-таки не все.

Маркетингові назви заплутують - у них, для більшої важливості, вказують кількість функціональних модулів, які вміють віднімати та множити: наприклад, 320 векторних "cores" (ядер). Ці ядра більше нагадують зерна. Краще представляти GPU як багатоядерний процесор з великою кількістю ядер, що виконують одночасно безліч ниток.

Кожен мініпроцесор має локальну пам'ять, розміром 16 KБ для GT200, 32 KБ – для Evergreen та 64 KБ – для Fermi (по суті, це програмований L1 кеш). Вона має схоже з кешем першого рівня стандартного CPU час доступу і виконує аналогічні функції якнайшвидшої доставки даних до функціональних модулів. В архітектурі Fermi частина локальної пам'яті може бути налаштована як звичайний кеш. У GPU локальна пам'ять служить для швидкого обміну даними між нитками, що виконуються. Одна із звичайних схем GPU-програми така: на початку локальну пам'ять завантажуються дані з глобальної пам'яті GPU. Це просто звичайна відеопам'ять, розташована (як і системна пам'ять) окремо від «свого» процесора – у разі відео вона розпаяна кількома мікросхемами на текстоліті відеокарти. Далі кілька сотень ниток працюють із цими даними локальної пам'яті і записують результат у глобальну пам'ять, після чого той передається в CPU. До обов'язку програміста входить написання інструкцій завантаження та вивантаження даних із локальної пам'яті. По суті це розбиття даних [конкретної задачі] для паралельної обробки. GPU підтримує також інструкції запису/читання в пам'ять, але вони неефективні і затребувані зазвичай на завершальному етапі для «склейки» результатів обчислень всіх мініпроцесорів.

Локальна пам'ять загальна для всіх ниток, що виконуються в мініпроцесорі, тому, наприклад, в термінології Nvidia вона навіть називається shared, а терміном local memory позначається прямо протилежне, а саме: якась персональна область окремої нитки в глобальній пам'яті, видима і доступна тільки їй. Але крім локальної пам'яті в мініпроцесор є ще одна область пам'яті, у всіх архітектурах приблизно в чотири рази більша за обсягом. Вона розділена порівну між усіма нитками, що виконуються, це регістри для зберігання змінних і проміжних результатів обчислень. На кожну нитку припадає кілька десятків регістрів. Точна кількість залежить від того, скільки ниток виконує мініпроцесор. Ця кількість дуже важлива, так як латентність глобальної пам'яті дуже велика, сотні тактів, і без кешів немає де зберігати проміжні результати обчислень.

І ще одна важлива риса GPU: "м'яка" векторність. Кожен мініпроцесор має велику кількість обчислювальних модулів (8 для GT200, 16 для Radeon і 32 для Fermi), але всі вони можуть виконувати тільки одну і ту ж інструкцію, з однією програмною адресою. Операнди ж у своїй можуть бути різні, в різних ниток свої. Наприклад, інструкція скласти вміст двох регістрів: вона одночасно виконується всіма обчислювальними пристроями, але регістри беруться різні. Передбачається, що всі нитки GPU-програми, здійснюючи паралельну обробку даних, загалом рухаються паралельним курсом за кодом програми. Таким чином, усі обчислювальні модулі завантажуються рівномірно. А якщо нитки через розгалуження у програмі розійшлися у своєму шляху виконання коду, то відбувається так звана серіалізація. Тоді використовуються в повному обсязі обчислювальні модулі, оскільки нитки подають виконання різні інструкції, а блок обчислювальних модулів може виконувати, як ми вже сказали, лише інструкцію з однією адресою. І, зрозуміло, продуктивність у своїй падає стосовно максимальної.

Плюсом є те, що векторизація відбувається повністю автоматично, це не програмування з використанням SSE, MMX тощо. І GPU сам обробляє розбіжності. Теоретично можна взагалі писати програми для GPU, не думаючи про векторну природу виконуючих модулів, але швидкість такої програми буде не дуже високою. Мінус полягає у великій ширині вектора. Вона більша, ніж номінальна кількість функціональних модулів, і становить 32 для GPU Nvidia та 64 для Radeon. Нитки обробляються блоками відповідного розміру. Nvidia називає цей блок ниток терміном warp, AMD - wave front, що те саме. Таким чином, на 16 обчислювальних пристроях "хвильовий фронт" довжиною 64 нитки обробляється за чотири такти (за умови звичайної довжини інструкції). Автор віддає перевагу в даному випадку терміну warp, через асоціацію з морським терміном warp, що позначає пов'язаний зі скручених мотузок канат. Так і нитки «скручуються» та утворюють цільну зв'язку. Втім, "wave front" теж може асоціюватися з морем: інструкції так само прибувають до виконавчих пристроїв, як хвилі одна за одною накочуються на берег.

Якщо всі нитки однаково просунулися у виконанні програми (перебувають у одному місці) і, таким чином, виконують одну інструкцію, то все чудово, але якщо ні – відбувається уповільнення. У цьому випадку нитки з одного warp або wave front знаходяться в різних місцях програми, вони розбиваються на групи ниток, що мають однакове значення номера інструкції (іншими словами, покажчика інструкцій (instruction pointer)). І як і раніше виконуються одночасно часу нитки однієї групи - всі виконують однакову інструкцію, але з різними операндами. У результаті warp здійснюється в стільки разів повільніше, на скільки груп він розбитий, а кількість ниток у групі не має значення. Навіть якщо група складається з однієї нитки, все одно вона буде виконуватися стільки ж часу, скільки повний warp. У залізі це реалізовано за допомогою маскування певних ниток, тобто інструкції формально виконуються, але результати виконання нікуди не записуються і надалі не використовуються.

Хоча в кожен момент часу кожен мініпроцесор (Streaming MultiProcessor або SIMD Engine) виконує інструкції, що належать лише одному warp (зв'язці ниток), він має кілька десятків активних варпів у пулі, що виконується. Виконавши інструкції одного варпа, мініпроцесор виконує не наступну по черзі інструкцію ниток даного варпа, а інструкції когось іншого варпа. Той варп може бути в зовсім іншому місці програми, це не впливатиме на швидкість, тому що тільки всередині варпа інструкції всіх ниток повинні бути однаковими для виконання з повною швидкістю.

У даному випадку кожен з 20 SIMD Engine має чотири активні wave front, у кожному з яких 64 нитки. Кожна нитка позначена короткою лінією. Всього: 64×4×20=5120 ниток

Таким чином, з огляду на те, що кожен warp або wave front складається з 32-64 ниток, мініпроцесор має кілька сотень активних ниток, які виконуються практично одночасно. Нижче ми побачимо, які архітектурні вигоди обіцяє така велика кількість паралельних ниток, але спочатку розглянемо, які обмеження є у складових GPU мініпроцесорів.

Головне, що в GPU немає стека, де могли б зберігатись параметри функцій та локальні змінні. Через велику кількість ниток для стека просто немає місця на кристалі. Дійсно, так як GPU одночасно виконує близько 10000 ниток, при розмірі стека однієї нитки в 100 КБ сукупний об'єм складе 1 ГБ, що дорівнює стандартному об'єму відеопам'яті. Тим більше, немає ніякої можливості помістити стек скільки-небудь істотного розміру в самому ядрі GPU. Наприклад, якщо покласти 1000 байт стека на нитку, то тільки на один мініпроцесор знадобиться 1 МБ пам'яті, що майже в п'ять разів більше за сукупний обсяг локальної пам'яті мініпроцесора і пам'яті, відведеної на зберігання регістрів.

Тому в GPU-програмі немає рекурсії, і з викликами функцій особливо не розгорнешся. Усі функції безпосередньо підставляються код при компіляції програми. Це обмежує сферу застосування GPU задачами обчислювального типу. Іноді можна використовувати обмежену емуляцію стека з використанням глобальної пам'яті для рекурсійних алгоритмів з відомою невеликою глибиною ітерацій, але це нетипове застосування GPU. Для цього необхідно спеціально розробляти алгоритм, досліджувати можливість його реалізації без гарантії успішного прискорення порівняно з CPU.

У Fermi вперше з'явилася можливість використовувати віртуальні функції, але їх застосування лімітовано відсутністю великого швидкого кеша для кожної нитки. На 1536 ниток припадає 48 КБ або 16 КБ L1, тобто віртуальні функції в програмі можна використовувати відносно рідко, інакше для стека також використовуватиметься повільна глобальна пам'ять, що уповільнить виконання і, швидше за все, не принесе вигод порівняно з CPU-варіантом.

Таким чином, GPU представляється в ролі обчислювального співпроцесора, який завантажуються дані, вони обробляються деяким алгоритмом, і видається результат.

Переваги архітектури

Але вважає GPU дуже швидко. І цьому йому допомагає його висока мультипоточность. Велика кількість активних ниток дозволяє частково приховати велику латентність розташованої окремо глобальної відеопам'яті, що становить близько 500 тактів. Особливо добре вона нівелюється для коду з високою густиною арифметичних операцій. Таким чином, не потрібна дорога з точки зору транзисторів ієрархія кешів L1-L2-L3. Замість неї на кристалі можна розмістити множину обчислювальних модулів, забезпечивши видатну арифметичну продуктивність. А поки виконуються інструкції однієї нитки чи варпа, решта сотень ниток спокійно чекає на свої дані.

У Fermi було введено кеш другого рівня розміром близько 1 МБ, але його не можна порівнювати з кешами сучасних процесорів, він більше призначений для комунікації між ядрами та різноманітними програмними трюками. Якщо його розмір розділити між усіма десятками тисяч ниток, на кожну прийдеться зовсім незначний обсяг.

Але крім латентності глобальної пам'яті, в обчислювальному пристрої існує ще безліч латентностей, які треба приховати. Це латентність передачі всередині кристала від обчислювальних пристроїв до кешу першого рівня, тобто локальної пам'яті GPU, і до регістрів, а також кешу інструкцій. Регістровий файл, як і локальна пам'ять, розташовані окремо від функціональних модулів, і швидкість доступу до них становить приблизно півтора десятки тактів. І знову ж таки велика кількість ниток, активних варпів, дозволяє ефективно приховати цю латентність. Причому загальна смуга пропускання (bandwidth) доступу до локальної пам'яті всього GPU, з урахуванням кількості складових мініпроцесорів, значно більше, ніж bandwidth доступу до кешу першого рівня у сучасних CPU. GPU може переробити значно більше даних за одиницю часу.

Можна відразу сказати, що якщо GPU не буде забезпечений великою кількістю паралельних ниток, то він матиме майже нульову продуктивність, тому що він працюватиме з тим же темпом, начебто повністю завантажений, а виконуватиме набагато менший обсяг роботи. Наприклад, нехай замість 10000 ниток залишиться лише одна: продуктивність впаде приблизно в тисячу разів, бо не тільки не всі блоки будуть завантажені, а й позначаться всі латентності.

Проблема приховування латентностей є гострою і для сучасних високочастотних CPU, для її усунення використовуються витончені способи - глибока конвеєризація, позачергове виконання інструкцій (out-of-order). Для цього потрібні складні планувальники виконання інструкцій, різні буфери і т. п., що займає місце на кристалі. Все це потрібно для кращої продуктивності в однопотоковому режимі.

Але для GPU все це не потрібне, він архітектурно швидше для обчислювальних завдань з великою кількістю потоків. Зате він перетворює багатопоточність на продуктивність, як філософський камінь перетворює свинець на золото.

GPU спочатку був пристосований для оптимального виконання шейдерних програм для пікселів трикутників, які, очевидно, є незалежними і можуть виконуватися паралельно. І з цього стану він еволюціонував шляхом додавання різних можливостей (локальної пам'яті та адресованого доступу до відеопам'яті, а також ускладнення набору інструкцій) до дуже потужного обчислювального пристрою, який все ж таки може бути ефективно застосований тільки для алгоритмів, що допускають високопаралельну реалізацію з використанням обмеженого обсягу локальної пам'яті.

приклад

Одне з класичних завдань для GPU - це завдання обчислення взаємодії N тіл, що створюють гравітаційне поле. Але якщо нам, наприклад, знадобиться розрахувати еволюцію системи Земля-Місяць-Сонце, то GPU нам поганий помічник: мало об'єктів. До кожного об'єкта треба обчислити взаємодії з усіма іншими об'єктами, які всього два. У разі руху Сонячної системи з усіма планетами та їх місяцями (приблизно кілька сотень об'єктів) GPU все ще не надто ефективний. Втім, і багатоядерний процесор через високі накладні витрати на управління потоками теж не зможе проявити всю свою міць, працюватиме в однопоточному режимі. Але якщо потрібно також розрахувати траєкторії комет і об'єктів поясу астероїдів, то це вже завдання для GPU, так як об'єктів достатньо, щоб створити необхідну кількість паралельних потоків розрахунку.

GPU також добре себе проявить, якщо необхідно розрахувати зіткнення кульових скупчень із сотень тисяч зірок.

Ще одна можливість використовувати потужність GPU у задачі N тіл з'являється, коли необхідно розрахувати безліч окремих завдань, хай і з невеликою кількістю тіл. Наприклад, якщо потрібно розрахувати варіанти еволюції однієї системи за різних варіантах початкових швидкостей. Тоді ефективно використати GPU вдасться без проблем.

Деталі мікроархітектури AMD Radeon

Ми розглянули базові принципи організації GPU, вони спільні для відеоприскорювачів усіх виробників, оскільки вони спочатку мали одне цільове завдання - шейдерні програми. Проте виробники знайшли можливість розійтися в деталях мікроархітектурної реалізації. Хоча і CPU різних вендорів часом сильно відрізняються, навіть сумісними, як, наприклад, Pentium 4 і Athlon або Core. Архітектура Nvidia вже досить широко відома, зараз ми розглянемо Radeon та виділимо основні відмінності у підходах цих вендорів.

Відеокарти AMD отримали повноцінну підтримку обчислень загального призначення починаючи з сімейства Evergreen, в якому також були вперше реалізовані специфікації DirectX 11. Картки сімейства 47xx мають низку суттєвих обмежень, які будуть розглянуті нижче.

Відмінності у розмірі локальної пам'яті (32 КБ у Radeon проти 16 КБ у GT200 і 64 КБ у Fermi) загалом не важливі. Як і розмір wave front у 64 нитках у AMD проти 32 ниток у warp у Nvidia. Практично будь-яку GPU-програму можна легко переконфігурувати та налаштувати на ці параметри. Продуктивність може змінитися на десятки відсотків, але у випадку з GPU це не так важливо, бо GPU-програма зазвичай працює в десять разів повільніше, ніж аналог для CPU, або в десять разів швидше, або взагалі не працює.

Більш важливим є використання AMD технології VLIW (Very Long Instruction Word). Nvidia використовує скалярні прості інструкції, що оперують зі скалярними регістрами. Її прискорювачі реалізують простий класичний RISC. Відеокартки AMD мають таку ж кількість регістрів, як GT200, але векторні регістри 128-бітні. Кожна VLIW-інструкція оперує кількома чотирикомпонентними 32-бітними регістрів, що нагадує SSE, але можливості VLIW набагато ширші. Це не SIMD (Single Instruction Multiple Data), як SSE - тут інструкції для кожної пари операнда можуть бути різними і навіть залежними! Наприклад, нехай компоненти регістру А називаються a1, a2, a3, a4; у регістра B – аналогічно. Можна обчислити за допомогою однієї інструкції, яка виконується за один такт, наприклад, число a1×b1+a2×b2+a3×b3+a4×b4 або двовимірний вектор (a1×b1+a2×b2, a3×b3+a4×b4 ).

Це стало можливим завдяки нижчій частоті GPU, ніж у CPU, та сильному зменшенню техпроцесів в останні роки. При цьому не потрібно ніякого планувальника, багато хто виконується за такт.

Завдяки векторним інструкціям, пікова продуктивність Radeon у числах одинарної точності дуже висока і вже становить терафлопи.

Один векторний регістр може замість чотирьох чисел одинарної точності зберігати число подвійний точності. І одна VLIW-інструкція може або скласти дві пари чисел double, або помножити два числа або помножити два числа і скласти з третім. Таким чином, пікова продуктивність у double приблизно в п'ять разів нижче, ніж у float. Для старших моделей Radeon вона відповідає продуктивності Nvidia Tesla на новій архітектурі Fermi та набагато вище, ніж продуктивність у double карток на архітектурі GT200. У споживчих відеокартах Geforce на основі Fermi максимальна швидкість double-обчислень була зменшена вчетверо.


Принципова схема роботи Radeon. Представлено лише один мініпроцесор із 20 паралельно працюючих

Виробники GPU, на відміну від виробників CPU (передусім x86-сумісних), не пов'язані питаннями сумісності. GPU-програма спочатку компілюється в якийсь проміжний код, а при запуску програми драйвер компілює цей код машинні інструкції, специфічні для конкретної моделі. Як було описано вище, виробники GPU скористалися цим, придумавши зручні ISA (Instruction Set Architecture) для своїх GPU та змінюючи їх від покоління до покоління. Це у будь-якому разі додало якісь відсотки продуктивності через відсутність (через непотрібність) декодера. Але компанія AMD пішла ще далі, вигадавши власний формат розташування інструкцій у машинному коді. Вони розташовані не послідовно (згідно з листингом програми), а по секціях.

Спочатку йде секція інструкцій щодо умовних переходів, які мають посилання на секції безперервних арифметичних інструкцій, що відповідають різним гілкам переходів. Вони називаються VLIW bundles (зв'язки VLIW-інструкцій). У цих секціях містяться лише арифметичні вказівки з даними з регістрів або локальної пам'яті. Така організація спрощує управління потоком інструкцій та доставку їх до виконавчих пристроїв. Це корисніше, враховуючи що VLIW-інструкції мають порівняно великий розмір. Існують також секції для інструкцій звернень до пам'яті.

Секції інструкцій умовних переходів
Секція 0Розгалуження 0Посилання на секцію №3 безперервних арифметичних інструкцій
Секція 1Розгалуження 1Посилання на секцію №4
Секція 2Розгалуження 2Посилання на секцію №5
Секції безперервних арифметичних інструкцій
Секція 3VLIW-інструкція 0VLIW-інструкція 1VLIW-інструкція 2VLIW-інструкція 3
Секція 4VLIW-інструкція 4VLIW-інструкція 5
Секція 5VLIW-інструкція 6VLIW-інструкція 7VLIW-інструкція 8VLIW-інструкція 9

GPU обох виробників (і Nvidia, і AMD) також мають вбудовані інструкції швидкого обчислення за кілька тактів основних математичних функцій, квадратного кореня, експоненти, логарифмів, синусів і косінусів для чисел одинарної точності. І тому є спеціальні обчислювальні блоки. Вони «відбулися» від необхідності реалізації швидкої апроксимації цих функцій у геометричних шейдерах.

Якби навіть хтось не знав, що GPU використовуються для графіки і ознайомився тільки з технічними характеристиками, то за цією ознакою він міг би здогадатися, що ці обчислювальні співпроцесори походять від відеоприскорювачів. Аналогічно, за деякими рисами морських ссавців, вчені зрозуміли, що їхні предки були сухопутними істотами.

Але більш очевидна риса, що видає графічне походження пристрою, це блоки читання двовимірних та тривимірних текстур за допомогою білінійної інтерполяції. Вони широко використовуються в GPU-програмах, оскільки забезпечують прискорене та спрощене читання масивів даних read-only. Одним із стандартних варіантів поведінки GPU-додатку є читання масивів вихідних даних, обробка їх у обчислювальних ядрах та запис результату в інший масив, який передається далі назад у CPU. Така схема стандартна та поширена, тому що зручна для архітектури GPU. Завдання, що вимагають інтенсивно читати та писати в одну велику область глобальної пам'яті, що містять, таким чином, залежність за даними, важко розпаралелити та ефективно реалізувати на GPU. Також їхня продуктивність сильно залежатиме від латентності глобальної пам'яті, яка дуже велика. А от якщо завдання описується шаблоном «читання даних – обробка – запис результату», то майже напевно можна отримати великий приріст від її виконання на GPU.

Для текстурних даних GPU існує окрема ієрархія невеликих кешів першого і другого рівнів. Вона і забезпечує прискорення від використання текстур. Ця ієрархія спочатку з'явилася в графічних процесорах для того, щоб скористатися локальністю доступу до текстур: очевидно, після обробки одного пікселя для сусіднього пікселя (з високою ймовірністю) знадобляться близько розташовані дані текстури. Але й багато алгоритмів традиційних обчислень мають подібний характер доступу до даних. Отже, текстурні кеші з графіки будуть дуже корисні.

Хоча розмір кешів L1-L2 у картках Nvidia і AMD приблизно подібний, що, очевидно, викликано вимогами оптимальності з погляду графіки ігор, латентність доступу до цих кешів суттєво відрізняється. Латентність доступу у Nvidia більша, і текстурні кеші Geforce в першу чергу допомагають скоротити навантаження на шину пам'яті, а не безпосередньо прискорити доступ до даних. Це не помітно у графічних програмах, але важливо для програм загального призначення. У Radeon латентність текстурного кеша нижче, зате вище латентність локальної пам'яті мініпроцесорів. Можна навести такий приклад: для оптимального перемноження матриць на картках Nvidia краще скористатися локальною пам'яттю, завантажуючи туди матрицю побічно, а для AMD краще покластися на текстурний кеш низьколатентний, читаючи елементи матриці при необхідності. Але це вже досить тонка оптимізація, і вже принципово перекладеного на GPU алгоритму.

Ця відмінність також проявляється у разі використання 3D-текстури. Один із перших бенчмарків обчислень на GPU, який показував серйозну перевагу AMD, якраз і використовував 3D-текстури, оскільки працював із тривимірним масивом даних. А латентність доступу до текстур у Radeon істотно швидше, і 3D-випадок додатково оптимізовано в залозі.

Для отримання максимальної продуктивності від заліза різних фірм потрібен певний тюнінг додатку під конкретну картку, але він значно менш істотний, ніж у принципі розробка алгоритму для архітектури GPU.

Обмеження серії Radeon 47xx

У цьому сімействі підтримка обчислень на GPU неповна. Можна відзначити три важливі моменти. По-перше, немає локальної пам'яті, тобто вона фізично є, але не має можливості універсального доступу, необхідного сучасним стандартом GPU-програм. Вона емулює програмно в глобальній пам'яті, тобто її використання на відміну від повнофункціонального GPU не принесе вигод. Другий момент – обмежена підтримка різних інструкцій атомарних операцій із пам'яттю та інструкцій синхронізації. І третій момент - це досить невеликий розмір кешу вказівок: починаючи з деякого розміру програми відбувається уповільнення швидкості в рази. Є й інші дрібні обмеження. Можна сказати, тільки програми, які ідеально підходять для GPU, будуть добре працювати на цій відеокартці. Нехай у простих тестових програмах, які оперують лише з регістрами, відеокарта може показувати хороший результат у Gigaflops, щось складне ефективно запрограмувати під неї проблематично.

Переваги та недоліки Evergreen

Якщо порівняти продукти AMD та Nvidia, то, з точки зору обчислень на GPU, серія 5xxx виглядає дуже потужним GT200. Такий потужний, що за піковою продуктивністю перевершує Fermi приблизно в два з половиною рази. Особливо після того, як параметри нових відеокарт Nvidia були урізані, скорочено кількість ядер. Але поява в Fermi кешу L2 спрощує реалізацію на GPU деяких алгоритмів, таким чином розширюючи сферу застосування GPU. Що цікаво, для добре оптимізованих під минуле покоління GT200 CUDA-програм архітектурні нововведення Fermi часто нічого не дали. Вони прискорилися пропорційно збільшенню кількості обчислювальних модулів, тобто менш ніж удвічі (для чисел одинарної точності), або навіть менше, бо ПСП пам'яті не збільшилася (чи з інших причин).

І в задачах, що добре лягають на архітектуру GPU, що мають виражену векторну природу (наприклад, перемноженні матриць), Radeon показує відносно близьку до теоретичного піку продуктивність і обганяє Fermi. Не кажучи вже про багатоядерні CPU. Особливо в задачах із числами з одинарною точністю.

Але Radeon має меншу площу кристала, менший тепловиділення, енергоспоживання, більший вихід придатних і, відповідно, меншу вартість. І безпосередньо в завданнях 3D-графіки виграш Fermi, якщо він взагалі є, набагато менший від різниці в площі кристала. Багато в чому це пояснюється тим, що обчислювальна архітектура Radeon з 16 обчислювальними пристроями на мініпроцесор, розміром wave front в 64 нитки та векторними VLIW-інструкціями є прекрасною для його головного завдання - обчислення графічних шейдерів. Для більшості звичайних користувачів продуктивність в іграх і ціна пріоритетні.

З погляду професійних, наукових програм, архітектура Radeon забезпечує найкраще співвідношення ціна-продуктивність, продуктивність на ват і абсолютну продуктивність у завданнях, які в принципі добре відповідають архітектурі GPU, допускають паралелізацію та векторизацію.

Наприклад, у повністю паралельній задачі підбору ключів Radeon, що легко векторизується, в кілька разів швидше Geforce і в кілька десятків разів швидше CPU.

Це відповідає загальній концепції AMD Fusion, згідно з якою GPU повинні доповнювати CPU, і в майбутньому інтегруватися в саме ядро ​​CPU, як раніше математичний співпроцесор був перенесений з окремого кристала в ядро ​​процесора (це сталося років двадцять тому перед появою перших процесорів Pentium). GPU буде інтегрованим графічним ядром та векторним співпроцесором для потокових завдань.

Radeon використовується хитра техніка змішування інструкцій з різних wave front при виконанні функціональними модулями. Це легко зробити, оскільки інструкції є повністю незалежними. Принцип аналогічний до конвеєрного виконання незалежних інструкцій сучасними CPU. Очевидно, це дозволяє ефективно виконувати складні, що займають багато байт, векторні VLIW-інструкції. У CPU для цього потрібен складний планувальник для виявлення незалежних інструкцій або використання технології Hyper-Threading, яка також забезпечує CPU свідомо незалежними інструкціями з різних потоків.

такт 0такт 1такт 2такт 3такт 4такт 5такт 6такт 7VLIW-модуль
wave front 0wave front 1wave front 0wave front 1wave front 0wave front 1wave front 0wave front 1
інстр. 0інстр. 0інстр. 16інстр. 16інстр. 32інстр. 32інстр. 48інстр. 48VLIW0
інстр. 1VLIW1
інстр. 2VLIW2
інстр. 3VLIW3
інстр. 4VLIW4
інстр. 5VLIW5
інстр. 6VLIW6
інстр. 7VLIW7
інстр. 8VLIW8
інстр. 9VLIW9
інстр. 10VLIW10
інстр. 11VLIW11
інстр. 12VLIW12
інстр. 13VLIW13
інстр. 14VLIW14
інстр. 15VLIW15

128 інструкцій двох wave front, кожен із яких складається з 64 операцій, виконуються 16 VLIW-модулями за вісім тактів. Відбувається чергування, і кожен модуль насправді має два такти на виконання цілої інструкції за умови, що він на другому такті почне виконувати нову паралельно. Ймовірно, це допомагає швидко виконати VLIW-інструкцію типу a1×a2+b1×b2+c1×c2+d1×d2, тобто виконати вісім таких інструкцій за вісім тактів. (Формально виходить, одну за такт.)

У Nvidia, мабуть, такої технології немає. І без VLIW, для високої продуктивності з використанням скалярних інструкцій потрібна висока частота роботи, що автоматично підвищує тепловиділення і пред'являє високі вимоги до технологічного процесу (щоб змусити працювати схему на вищій частоті).

Недоліком Radeon з точки зору GPU-обчислень є велика нелюбов до розгалужень. GPU взагалі не шанують розгалуження через вищеописану технологію виконання інструкцій: відразу групою ниток з однією програмною адресою. (До речі, така техніка називається SIMT: Single Instruction - Multiple Threads (одна інструкція - багато ниток), за аналогією з SIMD, де одна інструкція виконує одну операцію з різними даними.) Однак Radeon розгалуження не люблять особливо: це викликано більшим розміром зв'язування ниток . Зрозуміло, що якщо програма не повністю векторна, то чим більший розмір warp або wave front, тим гірше, тому що при розбіжності в дорозі за програмою сусідніх ниток утворюється більше груп, які необхідно виконувати послідовно (серіалізовано). Припустимо, всі нитки розбрелися, тоді у разі розміру warp у 32 нитки програма працюватиме у 32 рази повільніше. А у разі розміру 64, як у Radeon, – у 64 рази повільніше.

Це помітне, але не єдине прояв «неприязні». У відеокартах Nvidia кожен функціональний модуль, який називається CUDA core, має спеціальний блок обробки розгалужень. А у відеокартах Radeon на 16 обчислювальних модулів - всього два блоки управління розгалуженнями (вони виведені з домену арифметичних блоків). Так що навіть проста обробка інструкції умовного переходу, нехай її результат і однаковий для всіх ниток у wave front, займає додатковий час. І швидкість просідає.

Компанія AMD виробляє ще й CPU. Вони вважають, що для програм з великою кількістю розгалужень все одно краще підходить CPU, а GPU призначений для векторних програм.

Так що Radeon надає в цілому менше можливостей для ефективного програмування, але забезпечує найкраще співвідношення ціна-продуктивність у багатьох випадках. Іншими словами, програм, які можна ефективно (з користю) перевести з CPU на Radeon, менше, ніж програм, які ефективно працюють на Fermi. Проте ті, які ефективно перенести можна, працюватимуть на Radeon ефективніше в багатьох сенсах.

API для GPU-обчислень

Самі технічні специфікації Radeon виглядають привабливо, нехай і не варто ідеалізувати та абсолютизувати обчислення на GPU. Але не менш важливе для продуктивності програмне забезпечення, необхідне для розробки та виконання GPU-програми – компілятори з мови високого рівня та run-time, тобто драйвер, який здійснює взаємодію між частиною програми, що працює на CPU, та безпосередньо GPU. Воно навіть важливіше, ніж у випадку CPU: для CPU не потрібен драйвер, який здійснюватиме менеджмент передачі даних, і з погляду компілятора GPU більш вибагливий. Наприклад, компілятор повинен обійтися мінімальною кількістю регістрів для зберігання проміжних результатів обчислень, а також акуратно вбудовувати виклики функцій, знов-таки використовуючи мінімум регістрів. Адже чим менше регістрів використовує нитку, тим більше ниток можна запустити і повніше навантажити GPU, краще приховуючи час доступу до пам'яті.

І ось програмна підтримка продуктів Radeon поки що відстає від розвитку заліза. (На відміну від ситуації з Nvidia, де відкладався випуск заліза, і продукт вийшов у урізаному вигляді.) Ще недавно OpenCL-компілятор виробництва AMD мав статус бета, з безліччю недоробок. Він дуже часто генерував помилковий код або відмовлявся компілювати код із правильного вихідного тексту, або сам видавав помилку роботи та зависав. Тільки наприкінці весни вийшов реліз із високою працездатністю. Він теж не позбавлений помилок, але їх значно поменшало, і вони, як правило, виникають на бічних напрямках, коли намагаються запрограмувати щось на межі коректності. Наприклад, працюють з типом uchar4, який задає 4-байтову чотирикомпонентну змінну. Цей тип є в специфікаціях OpenCL, але працювати з ним на Radeon не варто, бо регістри-то 128-бітові: ті ж чотири компоненти, але 32-бітові. А така змінна uchar4 все одно займе цілий регістр, тільки потрібні будуть додаткові операції упаковки і доступу до окремих байтових компонентів. Компілятор не повинен мати жодних помилок, але компіляторів без недоліків не буває. Навіть Intel Compiler після 11 версій має помилки компіляції. Виявлені помилки виправлені в наступному релізі, який вийде ближче до осені.

Але є ще безліч речей, які потребують доопрацювання. Наприклад, стандартний GPU-драйвер для Radeon досі не має підтримки GPU-обчислень з використанням OpenCL. Користувач повинен завантажувати та встановлювати додатковий спеціальний пакет.

Але найголовніше - це відсутність будь-яких бібліотек функцій. Для дійсних чисел подвійної точності немає навіть синуса, косинуса та експоненти. Що ж, для складання-множення матриць цього не потрібно, але якщо ви хочете запрограмувати щось складніше, треба писати всі функції з нуля. Або чекати на новий реліз SDK. Незабаром має вийти ACML (AMD Core Math Library) для GPU-родини Evergreen з підтримкою основних матричних функцій.

На даний момент, на думку автора статті, реальним для програмування відеокарт Radeon є використання API Direct Compute 5.0, природно враховуючи обмеження: орієнтацію на платформу Windows 7 і Windows Vista. Microsoft має великий досвід у створенні компіляторів, і можна очікувати повністю працездатний реліз дуже скоро, Microsoft безпосередньо в цьому зацікавлена. Але Direct Compute орієнтований потреби інтерактивних додатків: щось порахувати і відразу візуалізувати результат - наприклад, перебіг рідини поверхнею. Це не означає, що його не можна використовувати просто для розрахунків, але це не є його природним призначенням. Скажімо, Microsoft не планує додавати в Direct Compute бібліотечні функції - саме ті, яких немає зараз у AMD. Тобто те, що зараз можна ефективно порахувати на Radeon – деякі не надто витончені програми, – можна реалізувати і на Direct Compute, який набагато простіше OpenCL і має бути стабільнішим. Плюс, він повністю портабельний, працюватиме і на Nvidia, і на AMD, так що компілювати програму доведеться лише один раз, тоді як реалізації OpenCL SDK компаній Nvidia та AMD не зовсім сумісні. (У тому сенсі, що якщо розробити OpenCL-програму на системі AMD з використанням AMD OpenCL SDK, вона може не піти так просто на Nvidia. Можливо, потрібно компілювати той самий текст із використанням Nvidia SDK. І, зрозуміло, навпаки.)

Потім, у OpenCL багато надмірної функціональності, оскільки OpenCL задумана як універсальна мова програмування та API для широкого кола систем. І GPU, і CPU, і Cell. Так що на випадок, якщо треба просто написати програму для типової системи користувача (процесор плюс відеокарта), OpenCL не представляється, так би мовити, «високопродуктивним». Кожна функція має десять параметрів, і дев'ять із них мають бути встановлені в 0. А для того, щоб встановити кожен параметр, треба викликати спеціальну функцію, яка теж має параметри.

І найголовніший поточний плюс Direct Compute – користувачу не треба встановлювати спеціальний пакет: все, що необхідно, вже є у DirectX 11.

Проблеми розвитку GPU-обчислень

Якщо взяти сферу персональних комп'ютерів, то ситуація така: існує не так багато завдань, для яких потрібна велика обчислювальна потужність і не вистачає звичайного двоядерного процесора. Начебто з моря на сушу вилізли великі ненажерливі, але неповороткі чудовиська, а на суші й є майже нічого. І споконвічні обителі земної поверхні зменшуються у розмірах, вчаться менше споживати, як завжди буває при дефіциті природних ресурсів. Якби зараз була така ж потреба у продуктивності, як 10-15 років тому, GPU-обчислення прийняли б на ура. А так проблеми сумісності та відносної складності GPU-програмування виходять на перший план. Краще написати програму, яка б працювала на всіх системах, ніж програму, яка працює швидко, але запускається тільки на GPU.

Дещо краще перспективи GPU з точки зору використання у професійних додатках та секторі робочих станцій, оскільки там більше потреби у продуктивності. З'являються плагіни для 3D-редакторів за допомогою GPU: наприклад, для рендерингу за допомогою трасування променів - не плутати зі звичайним GPU-рендеренгом! Щось з'являється і для 2D-редакторів та редакторів презентацій з прискоренням створення складних ефектів. Програми обробки відео також поступово мають підтримку GPU. Вищенаведені завдання через свою паралельну сутність добре лягають на архітектуру GPU, але зараз створена дуже велика база коду, налагодженого, оптимізованого під всі можливості CPU, так що потрібен час, щоб з'явилися хороші GPU-реалізації.

У цьому сегменті виявляються такі слабкі сторони GPU, як обмежений обсяг відеопам'яті - приблизно в 1 ГБ для звичайних GPU. Одним із головних факторів, що знижують продуктивність GPU-програм, є необхідність обміну даними між CPU та GPU по повільній шині, а через обмежений обсяг пам'яті доводиться передавати більше даних. І тут перспективною виглядає концепція AMD щодо поєднання GPU і CPU в одному модулі: можна пожертвувати високою пропускною здатністю графічної пам'яті для легкого і простого доступу до загальної пам'яті, до того ж з меншою латентністю. Ця висока ПСП нинішньої відеопам'яті DDR5 набагато більше користується попитом безпосередньо графічними програмами, ніж більшістю програм GPU-обчислень. Взагалі, загальна пам'ять GPU і CPU просто істотно розширить сферу застосування GPU, уможливить використання його обчислювальних можливостей у невеликих підзавданнях програм.

І найбільше GPU потрібні у сфері наукових обчислень. Вже збудовано кілька суперкомп'ютерів на базі GPU, які показують дуже високий результат у тесті матричних операцій. Наукові завдання такі різноманітні і численні, що завжди знаходиться безліч, яка чудово лягає на архітектуру GPU, для якої використання GPU дозволяє легко отримати високу продуктивність.

Якщо з усіх завдань сучасних комп'ютерів вибрати одну, це буде комп'ютерна графіка - зображення світу, у якому живемо. І оптимальна для цієї мети архітектура не може бути поганою. Це настільки важливе та фундаментальне завдання, що спеціально розроблене для неї залізо має нести в собі універсальність і бути оптимальним для різних завдань. Тим більше, що відеокартки успішно еволюціонують.

Часто постало питання: чому немає GPU прискорення в програмі Adobe Media Encoder CC? А те, що Adobe Media Encoder використовує GPU прискорення, ми з'ясували, а також відзначили нюанси його використання. Також зустрічається твердження: у програмі Adobe Media Encoder CC прибрали підтримку GPU прискорення. Це помилкова думка і випливає з того, що основна програма Adobe Premiere Pro CC тепер може працювати без прописаної та рекомендованої відеокарти, а для включення GPU движка в Adobe Media Encoder CC відеокарта повинна бути обов'язково прописана в документах: cuda_supported_cards або opencl_supported_cards. Якщо з чіпсетами nVidia все зрозуміло, просто беремо ім'я чіпсету та вписуємо його в документ cuda_supported_cards. То при використанні відеокарт AMD треба прописувати не ім'я чіпсету, а кодову назву ядра. Отже, на практиці перевіримо, як на ноутбуці ASUS N71JQ з дискретною графікою ATI Mobility Radeon HD 5730 включити GPU двигун в Adobe Media Encoder CC. Технічні дані графічного адаптера ATI Mobility Radeon HD 5730 показуються утилітою GPU-Z:

Запускаємо програму Adobe Premiere Pro CC і включаємо двигун: Mercury Playback Engine GPU Acceleration (OpenCL).

Три DSLR відео на таймлайні, один над одним, два з них, створюють ефект зображення в малюнку.

Ctrl+M, вибираємо пресет MPEG2-DVD, прибираємо чорні смуги з боків за допомогою опції Scale To Fill. Включаємо також підвищену якість для тестів без GPU: MRQ (Use Maximum Render Quality). Натискаємо кнопку: Export. Завантаження процесора до 20% та оперативної пам'яті 2.56 Гбайт.


Завантаження GPU чіпсету ATI Mobility Radeon HD 5730 складає 97% та 352Мб бортової відеопам'яті. Ноутбук тестувався під час роботи від акумулятора, тому графічне ядро/пам'ять працюють на знижених частотах: 375/810 МГц.

Підсумковий час прорахунку: 1 хвилина та 55 секунд(вкл/вимк. MRQ при використанні GPU движка, не впливає на підсумковий час прорахунку).
При встановленій галці Use Maximum Render Quality тепер натискаємо кнопку: Queue.


Тактові частоти процесора під час роботи від акумулятора: 930МГц.

Запускаємо AMEEncodingLog і дивимося підсумковий час прорахунку: 5 хвилин та 14 секунд.

Повторюємо тест, але вже при знятій галці Use Maximum Render Quality натискаємо на кнопку: Queue.

Підсумковий час прорахунку: 1 хвилина та 17 секунд.

Тепер увімкнемо GPU двигун в Adobe Media Encoder CC, запускаємо програму Adobe Premiere Pro CC, натискаємо комбінацію клавіш: Ctrl + F12, виконуємо Console > Console View і в полі Command вбиваємо GPUSniffer, натискаємо Enter.


Виділяємо та копіюємо ім'я в GPU Computation Info.

У директорії програми Adobe Premiere Pro CC відкриваємо документ opencl_supported_cards і в алфавітному порядку вбиваємо кодове ім'я чіпсету, Ctrl+S.

Натискаємо кнопку: Queue, і отримуємо GPU прискорення прорахунку проекту Adobe Premiere Pro CC в Adobe Media Encoder CC.

Підсумковий час: 1 хвилина та 55 секунд.

Підключаємо ноутбук до розетки і повторюємо результати прорахунків. Queue, галка MRQ знята, без включення двигуна, завантаження оперативної пам'яті трохи підросло:


Тактові частоти процесора: 1.6ГГц під час роботи від розетки та включення режиму: Висока продуктивність.

Підсумковий час: 46 секунд.

Включаємо двигун: Mercury Playback Engine GPU Acceleration (OpenCL), як видно від мережі ноутбукова відеокарта працює на своїх базових частотах, завантаження GPU в Adobe Media Encoder CC досягає 95%.

Підсумковий час прорахунку, що знизився з 1 хвилина 55 секунд, до 1 хвилини та 5 секунд.

*Для візуалізації в Adobe Media Encoder CC тепер використовується графічний процесор (GPU). Підтримуються стандарти CUDA та OpenCL. В Adobe Media Encoder CC, двигун GPU використовується для наступних процесів візуалізації:
- Зміна чіткості (від високої до стандартної та навпаки).
- Фільтр тимчасового коду.
- Перетворення формату пікселів.
- Розмежування.
Якщо візуалізується проект Premiere Pro, AME використовує установки візуалізації з GPU, задані для цього проекту. При цьому будуть використані всі можливості візуалізації з GPU, реалізовані Premiere Pro. Для візуалізації проектів AME використовується обмежений набір можливостей для візуалізації з GPU. Якщо послідовність візуалізується за допомогою оригінальної підтримки, застосовується налаштування GPU з AME, налаштування проекту ігнорується. У цьому випадку всі можливості візуалізації з GPU Premiere Pro використовуються безпосередньо в AME. Якщо проект містить VST сторонніх виробників, використовується налаштування проекту GPU. Послідовність кодується за допомогою PProHeadless, як і в попередніх версіях AME. Якщо прапорець Enable Native Premiere Pro Sequence Import (Дозволити імпорт вихідної послідовності Premiere Pro) знято, завжди використовується PProHeadless та налаштування GPU.

Читаємо про прихований розділ на системному диску ноутбука ASUS N71JQ.

Ядер багато не буває.

Сучасні GPU – це монструозні спритні бестії, здатні пережовувати гігабайти даних. Однак людина хитра і, як би не зростали обчислювальні потужності, вигадує завдання все складніше і складніше, так що настає момент, коли з сумом доводиться констатувати – потрібна оптимізацію 🙁

У цій статті описані основні поняття, щоб було легше орієнтуватися в теорії gpu-оптимізації та базові правила, для того щоб до цих понять, доводилося звертатися рідше.

Причини, за якими GPU ефективні для роботи з великими обсягами даних, що вимагають обробки:

  • у них великі можливості щодо паралельного виконання завдань (багато-багато процесорів)
  • висока пропускна здатність у пам'яті

Пропускна спроможність пам'яті (memory bandwidth)– це скільки інформації – біт чи гігабайт – може бути передана за одиницю часу секунду чи процесорний такт.

Одне із завдань оптимізації – задіяти по максимуму пропускну здатність – збільшити показники черезput(В ідеалі вона повинна дорівнювати memory bandwidth).

Для покращення використання пропускної спроможності:

  • збільшити обсяг інформації – використовувати пропускний канал на повну (наприклад, кожен потік працює з флоат4)
  • зменшувати латентність – затримку між операціями

Затримка (latency)- проміжок часу між моментами, коли контролер запросив конкретну комірку пам'яті та тим моментом, коли дані стали доступні процесору для виконання інструкцій. На саму затримку ми ніяк не можемо вплинути – ці обмеження присутні на апаратному рівні. Саме за рахунок цієї затримки процесор може одночасно обслуговувати кілька потоків - поки потік А запросив виділити йому пам'яті, потік Б може щось порахувати, а потік С чекати до нього прийдуть запитані дані.

Як знизити затримку (latency) якщо використовується синхронізація:

  • зменшити кількість потоків у блоці
  • збільшити кількість груп-блоків

Використання ресурсів GPU на повну – GPU Occupancy

У високолобих розмовах про оптимізацію часто миготить термін. gpu occupancyабо kernel occupancy- Він відображає ефективність використання ресурсів-потужностей відеокарти. Окремо зазначу - якщо ви навіть і використовуєте всі ресурси - це зовсім не означає, що ви використовуєте їх правильно.

Обчислювальні потужності GPU – це сотні процесорів жадібних до обчислень, при створенні програми – ядра (kernel) – на плечі програміста лягатиме тягар розподілу навантаження на них. Помилка може призвести до того, що більшість цих дорогоцінних ресурсів може безцільно простоювати. Зараз я поясню чому. Почати доведеться здалеку.

Нагадаю, що варп ( warp у термінології NVidia, wavefront – в термінології AMD) – набір потоків, які одночасно виконують одну і ту ж функцію-кернел на процесорі. Потоки, об'єднані програмістом в блоки, розбиваються на варпи планувальником потоків (окремо для кожного мультипроцесора) – поки один варп працює, другий чекає на обробку запитів до пам'яті і т.д. Якщо якісь із потоків варпа все ще виконують обчислення, а інші вже зробили все, що могли – має місце неефективне використання обчислювального ресурсу – в народі іменоване простоювання потужностей.

Кожна точка синхронізації, кожне розгалуження логіки може спричинити таку ситуацію простою. Максимальна дивергенція (розгалуження логіки виконання) залежить від розміру варпа. Для GPU від NVidia – це 32, для AMD – 64.

Для того щоб знизити простий мультипроцесор під час виконання варпа:

  • мінімізувати час очікування бар'єрів
  • мінімізувати розбіжність логіки виконання у функції-кернелі

Для ефективного розв'язання цього завдання є сенс розібратися – як відбувається формування варпів (для випадку з кількома розмірностями). Насправді порядок простий - в першу чергу X, потім Y і, в останню чергу, Z.

ядро запускається з блоками розмірністю 64×16, потоки розбиваються по варпам порядку X, Y, Z – тобто. перші 64 елементи розбиваються на два варпи, потім другі і т.д.

Ядро запускається із блоками розмірністю 16×64. У перший варп додаються перші та другі 16 елементів, у другий варп – треті та четверті тощо.

Як знижувати дивергенцію (пам'ятаєте – розгалуження – не завжди причина критичної втрати продуктивності)

  • коли у суміжних потоків різні шляхи виконання - багато умов і переходів по них - шукати шляхи реструктуризації
  • шукати не збалансоване завантаження потоків і рішуче її видаляти (це коли у нас мало того, що є умови, так ще через ці умови перший потік завжди щось обчислює, а п'ятий в цю умову не потрапляє і простоює)

Як використовувати ресурси GPU по максимуму

Ресурси GPU, на жаль, теж мають обмеження. І, строго кажучи, перед запуском функції-кернела є сенс визначити ліміти і при розподілі навантаження ці ліміти врахувати. Чому це важливо?

У відеокарт є обмеження на загальне число потоків, яке може виконувати один мультипроцесор, максимальна кількість потоків в одному блоці, максимальна кількість варпів на одному процесорі, обмеження різних видів пам'яті і т.п. Всю цю інформацію можна запросити як програмно, через відповідне API так і за допомогою утиліт з SDK. (Модулі deviceQuery для NVidia, CLInfo – для відеокарт AMD).

Загальна практика:

  • кількість блоків/робочих груп потоків повинна бути кратна кількості потокових процесорів
  • розмір блоку/робочої групи повинен бути кратний розміру варпа

При цьому слід враховувати, що абсолютний мінімум – 3-4 варпи/вейфронти крутяться одночасно на кожному процесорі, мудрі гайди радять виходити з міркування – не менше семи вейфронатів. При цьому – не забувати обмеження щодо заліза!

У голові всі ці деталі тримати швидко набридає, тому для розрахунків gpu-occupancy NVidia запропонувала несподіваний інструмент - ексельний (!) калькулятор набитий макросами. Туди можна ввести інформацію по максимальній кількості потоків для SM, кількість регістрів і розмір загальної (shared) пам'яті доступних на потоковому процесорі, і параметри запуску функцій, що використовуються - а він видає у відсотках ефективність використання ресурсів (і ви рвете на голові волосся усвідомлюючи що задіяти всі ядра вам не вистачає регістрів).

інформація щодо використання:
http://docs.nvidia.com/cuda/cuda-c-best-practices-guide/#calculating-occupancy

GPU та операції з пам'яттю

Відеокарти оптимізовані для 128-бітових операцій із пам'яттю. Тобто. в ідеалі – кожна маніпуляція з пам'яттю, в ідеалі повинна змінювати за раз 4 чотирибайтних значення. Основна проблема для програміста полягає в тому, що сучасні компілятори для GPU не можуть оптимізувати такі речі. Це доводиться робити у коді функції і, у середньому, приносить частки-відсотка з приросту продуктивності. Набагато більший вплив на продуктивність має частота запитів до пам'яті.

Проблема в наступному - кожен запит повертає у відповідь шматочок даних розміром кратний 128 біт. А кожен потік використовує лише чверть його (у разі звичайної чотирибайтової змінної). Коли суміжні потоки одночасно працюють з даними розташованими послідовно в осередках пам'яті, це знижує загальну кількість звернень до пам'яті. Називається це явище - об'єднані операції читання та запису ( coalesced access – good! both read and write) – і за правильної організації коду ( strided access to contiguous chunk of memory – bad!) може відчутно покращити продуктивність. При організації свого ядра – пам'ятайте – суміжний доступ – у межах елементів одного рядка пам'яті, робота з елементами стовпця – це вже не так ефективно. Бажаєте більше деталей? мені сподобалася ось ця pdf – або гуглить на предмет memory coalescing techniques “.

Лідируючі позиції в номінації "вузьке місце" займає інша операція з пам'яттю - копіювання даних з пам'яті хоста в ГПУ . Копіювання відбувається не аби як, а із спеціально виділеної драйвером і системою області пам'яті: при запиті на копіювання даних – система спочатку копіює туди ці дані, а вже потім заливає їх у GPU. Швидкість транспортування даних обмежена пропускною спроможністю шини PCI Express xN (де N число ліній передачі даних), через які сучасні відеокарти спілкуються з хостом.

Проте, надмірне копіювання повільної пам'яті на хості – це часом невиправдані витрати. Вихід – використовувати так звану pinned memory - Спеціальним чином позначену область пам'яті, так що операційна система не має можливості виконувати з нею якісь операції (наприклад - вивантажити у свап / перемістити на свій розсуд і т.п.). Передача даних із хоста на відеокарту здійснюється без участі операційної системи- асинхронно, через DMA (Direct Memory Access).

І, насамкінець, ще трохи про пам'ять. Пам'ять, що розділяється на мультипроцесорі зазвичай організована у вигляді банків пам'яті містять 32 бітні слова - дані. Кількість банків за доброю традицією варіюється від одного покоління GPU до іншого - 16/32 Якщо кожен потік звертається за даними в окремий банк - все гаразд. Інакше виходить кілька запитів на читання/запис до одного банку і ми отримуємо конфлікт ( shared memory bank conflict). Такі конфліктні звернення серіалізуються і виконуються послідовно, а чи не паралельно. Якщо до одного банку звертаються всі потоки - використовується "широкомовна" відповідь ( broadcast) і конфлікту немає. Існує кілька способів ефективно боротися з конфліктами доступу, мені сподобалося опис основних методик звільнення від конфліктів доступу до банків пам'яті – .

Як зробити математичні операції ще швидше? Пам'ятати що:

  • обчислення подвійної точності – це високе навантаження операції з FP64 >> FP32
  • константи виду 3.13 у коді, за умовчанням, інтерпретується як fp64 якщо явно не вказувати 3.14f
  • для оптимізації математики не зайвим буде впоратися в гайдах - а чи немає яких прапорців компілятор
  • виробники включають у свої SDK функції, які використовують особливості пристроїв для досягнення продуктивності (часто – на шкоду переносимості)

Для розробників CUDA є сенс звернути пильну увагу на концепцію cuda stream,що дозволяють запускати відразу кілька функцій-ядер на одному пристрої або поєднувати асинхронне копіювання даних з хоста на пристрій під час виконання функцій. OpenCL поки такого функціоналу не надає 🙁

Утиль для профілювання:

NVifia Visual Profiler – цікава утилітка, що аналізує ядра як CUDA так і OpenCL.

P. S. Як більш розлогий посібник з оптимізації, можу порекомендувати гуглити всілякі best practices guide для OpenCL та CUDA.

  • ,

Сьогодні новини про використання графічних процесорів для загальних обчислень можна почути на кожному розі. Такі слова, як CUDA, Stream і OpenCL, за якихось два роки стали чи не найцитованішими в інтернеті. Однак, що означають ці слова, і що несуть технології, що стоять за ними, відомо далеко не кожному. А для лінуксоїдів, які звикли "бути в прольоті", так і взагалі все це бачиться темним лісом.

Народження GPGPU

Ми всі звикли думати, що єдиним компонентом комп'ютера, здатним виконувати будь-який код, який йому накажуть, є центральний процесор. Довгий час багато масових ПК оснащувалися єдиним процесором, який займався всіма можливими розрахунками, включаючи код операційної системи, всього нашого софту і вірусів.

Пізніше з'явились багатоядерні процесориі багатопроцесорні системи, у яких таких компонентів було кілька. Це дозволило машинам виконувати кілька завдань одночасно, а загальна (теоретична) продуктивність системи піднялася рівно стільки разів, скільки ядер було встановлено у машині. Однак виявилося, що виробляти та конструювати багатоядерні процесори надто складно та дорого.

У кожному ядрі доводилося розміщувати повноцінний процесор складної та заплутаної x86-архітектури, зі своїм (досить об'ємним) кешем, конвеєром інструкцій, блоками SSE, безліччю блоків, що виконують оптимізації тощо. і т.п. Тому процес нарощування кількості ядер суттєво загальмувався, і білі університетські халати, яким два чи чотири ядра було явно мало, знайшли спосіб задіяти для своїх наукових розрахунків інші обчислювальні потужності, яких було в достатку на відеокарті (в результаті навіть з'явився інструмент BrookGPU, що емулює додатковий процесорза допомогою функцій DirectX і OpenGL).

Графічні процесори, позбавлені багатьох недоліків центрального процесора, виявилися відмінною і дуже швидкою лічильною машинкою, і незабаром до напрацювань вчених умів почали придивлятися самі виробники GPU (а nVidia так і взагалі найняла більшість дослідників на роботу). В результаті з'явилася технологія nVidia CUDA, що визначає інтерфейс, за допомогою якого стало можливим перенести обчислення складних алгоритмів на плечі GPU без милиць. Пізніше за нею пішла ATi (AMD) з власним варіантом технології під назвою Close to Metal (нині Stream), а незабаром з'явилася версія від Apple, що стала стандартом, що отримала ім'я OpenCL.

GPU – наше все?

Незважаючи на всі переваги, техніка GPGPU має кілька проблем. Перша з них полягає у дуже вузькій сфері застосування. GPU зробили крок далеко вперед центрального процесора в плані нарощування обчислювальної потужності і загальної кількості ядер (відеокарти несуть на собі обчислювальний блок, що складається з більш ніж сотні ядер), проте така висока щільність досягається за рахунок максимального спрощення дизайну самого чіпа.

По суті основне завдання GPU зводиться до математичним розрахункамза допомогою простих алгоритмів, що отримують на вхід невеликі обсяги передбачуваних даних. З цієї причини ядра GPU мають дуже простий дизайн, мізерні обсяги кешу та скромний набір інструкцій, що зрештою і виливається в дешевизну їхнього виробництва та можливість дуже щільного розміщення на чіпі. GPU схожі на китайську фабрику із тисячами робітників. Якісь прості речі вони роблять досить добре (а головне — швидко і дешево), але якщо довірити їм збирання літака, то в результаті вийде максимум дельтаплану.

Тому перше обмеження GPU — це орієнтованість на швидкі математичні розрахунки, що обмежує сферу застосування графічних процесорів за допомогою мультимедійних додатків, а також будь-яких програм, що займаються складною обробкою даних (наприклад, архіваторів або систем шифрування, а також софтин, що займаються флуоресцентною мікроскопією, молекул динамікою, електростатикою та іншими, малоцікавими для лінуксоїдів речами).

Друга проблема GPGPU в тому, що адаптувати до виконання на GPU можна далеко не кожен алгоритм. Окремо взяті ядра графічного процесора досить повільні, та його потужність проявляється лише за роботі спільно. А це означає, що алгоритм буде настільки ефективним, наскільки ефективно зможе розпаралелити програміст. Найчастіше з такою роботою може впоратися лише хороший математик, яких серед розробників софту зовсім небагато.

І третє: графічні процесори працюють з пам'яттю, встановленої на самій відеокарті, так що при кожному задіянні GPU відбуватиметься дві додаткові операції копіювання: вхідні дані з оперативної пам'яті програми та вихідні дані з GRAM назад в пам'ять програми. Неважко здогадатися, що це може звести нанівець весь виграш у часі роботи програми (як і відбувається у випадку з інструментом FlacCL, який ми розглянемо пізніше).

Але це ще не все. Незважаючи на існування загальновизнаного стандарту в особі OpenCL, багато програмістів досі вважають за краще використовувати прив'язані до виробника реалізації техніки GPGPU. Особливо популярною виявилася CUDA, яка хоч і дає більш гнучкий інтерфейс програмування (до речі, OpenCL драйвери nVidiaреалізований поверх CUDA), але намертво прив'язує додаток до відеокарт одного виробника.

KGPU чи ядро ​​Linux, прискорене GPU

Дослідники з університету Юти розробили систему KGPU, яка дозволяє виконувати деякі функції ядра Linux на графічному процесорі за допомогою фреймворку CUDA. Для виконання цього завдання використовується модифіковане ядро ​​Linux та спеціальний демон, який працює у просторі користувача, слухає запити ядра та передає їх драйверу відеокарти за допомогою бібліотеки CUDA. Цікаво, що незважаючи на суттєвий оверхід, який створює така архітектура, авторам KGPU вдалося створити реалізацію алгоритму AES, який піднімає швидкість шифрування. файлової системи eCryptfs у 6 разів.

Що зараз є?

Через свою молодість, а також завдяки описаним вище проблемам, GPGPU так і не стала по-справжньому поширеною технологією, проте корисний софт, що використовує її можливості, існує (хоч і у мізерній кількості). Одними з перших з'явилися крекери різних хешів, алгоритми роботи яких легко розпаралелити.

Також народилися мультимедійні програми, наприклад, кодувальник FlacCL, що дозволяє перекодувати звукову доріжку у формат FLAC. Підтримкою GPGPU обзавелися і деякі програми, що вже існували раніше, найпомітнішим з яких став ImageMagick, який тепер вміє перекладати частину своєї роботи на графічний процесор за допомогою OpenCL. Також є проекти з перекладу на CUDA/OpenCL (не люблять юніксоїди ATi) архіваторів даних та інших систем стиснення інформації. Найцікавіші з цих проектів ми розглянемо в наступних розділах статті, а поки що спробуємо розібратися з тим, що нам потрібно для того, щоб усе це завелося та стабільно працювало.

GPU вже давно обігнали x86-процесори у продуктивності

· По-друге, у систему мають бути встановлені останні пропрієтарні драйвери для відеокарти, вони забезпечать підтримку як рідних для картки технологій GPGPU, так і відкритого OpenCL.

· І по-третє, оскільки поки що дистрибутивобудівники ще не почали поширювати пакети додатків з підтримкою GPGPU, нам доведеться збирати програми самостійно, а для цього потрібні офіційні SDK від виробників: CUDA Toolkit або ATI Stream SDK. Вони містять у собі необхідні для збирання додатків заголовні файли та бібліотеки.

Ставимо CUDA Toolkit

Йдемо за наведеним вище посиланням і завантажуємо CUDA Toolkit для Linux (вибрати можна з кількох версій, для дистрибутивів Fedora, RHEL, Ubuntu і SUSE, є версії як для архітектури x86, так і для x86_64). Крім того, там же треба завантажити комплекти драйверів для розробників (Developer Drivers for Linux, вони йдуть першими у списку).

Запускаємо інсталятор SDK:

$ sudo sh cudatoolkit_4.0.17_linux_64_ubuntu10.10.run

Коли інсталяцію буде завершено, приступаємо до встановлення драйверів. Для цього завершуємо роботу X-сервера:

# sudo /etc/init.d/gdm stop

Відкриваємо консоль і запускаємо інсталятор драйверів:

$ sudo sh devdriver_4.0_linux_64_270.41.19.run

Після закінчення установки стартуємо ікси:

Щоб програми змогли працювати з CUDA/OpenCL, прописуємо шлях до каталогу з CUDA-бібліотеками до змінної LD_LIBRARY_PATH:

$ export LD_LIBRARY_PATH=/usr/local/cuda/lib64

Або, якщо ти встановив 32-бітну версію:

$ export LD_LIBRARY_PATH=/usr/local/cuda/lib32

Також необхідно прописати шлях до заголовних файлів CUDA, щоб їх компілятор знайшов на етапі складання програми:

$export C_INCLUDE_PATH=/usr/local/cuda/include

Все, тепер можна розпочати складання CUDA/OpenCL-софту.

Ставимо ATI Stream SDK

Stream SDK не вимагає установки, тому завантажений з сайту AMD-архів можна просто розпакувати в будь-який каталог ( найкращим виборомбуде /opt) і прописати шлях до нього у всю ту саму змінну LD_LIBRARY_PATH:

$ wget http://goo.gl/CNCNo

$ sudo tar -xzf ~/AMD-APP-SDK-v2.4-lnx64.tgz -C /opt

$ export LD_LIBRARY_PATH=/opt/AMD-APP-SDK-v2.4-lnx64/lib/x86_64/

$ export C_INCLUDE_PATH=/opt/AMD-APP-SDK-v2.4-lnx64/include/

Як і у випадку з CUDA Toolkit, x86_64 необхідно замінити на x86 у 32-бітових системах. Тепер переходимо до кореневого каталогу та розпаковуємо архів icd-registration.tgz (це свого роду безкоштовний ліцензійний ключ):

$ sudo tar -xzf /opt/AMD-APP-SDK-v2.4-lnx64/icd-registration.tgz -З/

Перевіряємо правильність установки/роботи пакета за допомогою інструменту clinfo:

$ /opt/AMD-APP-SDK-v2.4-lnx64/bin/x86_64/clinfo

ImageMagick та OpenCL

Підтримка OpenCL з'явилася в ImageMagick вже досить давно, проте за умовчанням вона не активована в жодному дистрибутиві. Тому нам доведеться зібрати IM самостійно із вихідних джерел. Нічого складного в цьому немає, все необхідне вже є в SDK, тому збірка не вимагатиме встановлення якихось додаткових бібліотек від nVidia або AMD. Отже, завантажуємо/розпаковуємо архів із вихідними джерелами:

$ wget http://goo.gl/F6VYV

$ tar -xjf ImageMagick-6.7.0-0.tar.bz2

$ cd ImageMagick-6.7.0-0

$ sudo apt-get install build-essential

Запускаємо конфігуратор і гріпаємо його висновок щодо підтримки OpenCL:

$ LDFLAGS=-L$LD_LIBRARY_PATH ./confi gure | grep -e cl.h -e OpenCL

Правильний результат роботи команди має виглядати приблизно так:

checking CL/cl.h usability... yes

checking CL/cl.h presence... yes

checking for CL/cl.h... yes

checking OpenCL/cl.h usability... no

checking OpenCL/cl.h presence... no

checking for OpenCL/cl.h... no

checking for OpenCL library... -lOpenCL

Словом "yes" повинні бути відмічені або перші три рядки, або другі (або обидва варіанти відразу). Якщо це не так, то, швидше за все, була неправильно ініціалізована змінна C_INCLUDE_PATH. Якщо ж словом "no" відзначено останній рядокОтже, справа в змінній LD_LIBRARY_PATH. Якщо все окей, запускаємо процес складання/установки:

$ sudo make install clean

Перевіряємо, що ImageMagick дійсно був скомпільований за допомогою OpenCL:

$/usr/local/bin/convert-version | grep Features

Features: OpenMP OpenCL

Тепер виміряємо отриманий виграш у швидкості. Розробники ImageMagick рекомендують використовувати для цього фільтр convolve:

$time /usr/bin/convert image.jpg -convolve "-1, -1, -1, -1, 9, -1, -1, -1, -1" image2.jpg

$time /usr/local/bin/convert image.jpg -convolve "-1, -1, -1, -1, 9, -1, -1, -1, -1" image2.jpg

Деякі інші операції, такі як ресайз, тепер теж повинні працювати значно швидше, проте сподіватися, що ImageMagick почне обробляти графіку з шаленою швидкістю, не варто. Поки що дуже мала частина пакету оптимізована за допомогою OpenCL.

FlacCL (Flacuda)

FlacCL - це кодувальник звукових файлів у формат FLAC, що працює у своїй роботі OpenCL. Він входить до складу пакету CUETools для Windows, але завдяки mono може бути використаний і Linux. Для отримання архіву з кодувальником виконуємо наступну команду:

$ mkdir flaccl && cd flaccl

$wget www.cuetools.net/install/flaccl03.rar

$ sudo apt-get install unrar mono

$ unrar x fl accl03.rar

Щоб програма змогла знайти бібліотеку OpenCL, робимо символічне посилання:

$ln -s $LD_LIBRARY_PATH/libOpenCL.so libopencl.so

Тепер запускаємо кодувальник:

$mono CUETools.FLACCL.cmd.exe music.wav

Якщо на екран буде виведено повідомлення про помилку "Error: Requested compile size is bigger than the required workgroup size of 32", значить, у нас в системі занадто слабка відеокарта, і кількість задіяних ядер слід скоротити до зазначеного числа за допомогою прапора '-- group-size XX', де XX - необхідна кількість ядер.

Відразу скажу, що через довгий час ініціалізації OpenCL помітний виграш можна отримати лише на досить довгих доріжках. Короткі звукові файли FlacCL обробляє майже так само швидко, як і його традиційна версія.

oclHashcat або брутфорс по-швидкому

Як я вже казав, одними з перших підтримку GPGPU у свої продукти додали розробники різних крекерів та систем брутфорсу паролів. Для них нова технологіястала справжнім святим граалем, який дозволив з легкістю перенести від природи код, що легко розпаралелюється, на плечі швидких GPU-процесорів. Тому не дивно, що зараз існують десятки різних реалізацій подібних програм. Але в цій статті я розповім лише про одну з них – oclHashcat.

oclHashcat - це ломалка, яка вміє підбирати паролі по їхньому хешу з екстремально. високою швидкістю, використовуючи при цьому потужності GPU за допомогою OpenCL. Якщо вірити вимірам, опублікованим на сайті проекту, швидкість підбору MD5-паролей на nVidia GTX580 становить до 15,8 млн комбінацій в секунду, завдяки чому oclHashcat здатний знайти середній за складністю восьмисимвольний пароль за якісь 9 хвилин.

Програма підтримує OpenCL та CUDA, алгоритми MD5, md5($pass.$salt), md5(md5($pass)), vBulletin< v3.8.5, SHA1, sha1($pass.$salt), хэши MySQL, MD4, NTLM, Domain Cached Credentials, SHA256, поддерживает распределенный подбор паролей с задействованием мощности нескольких машин.

$ 7z x oclHashcat-0.25.7z

$ cd oclHashcat-0.25

І запустити програму (скористаємося пробним списком хешів та пробним словником):

$ ./oclHashcat64.bin example.hash ?l?l?l?l example.dict

oclHashcat відкриє текст угоди, з якою слід погодитися, набравши "YES". Після цього почнеться процес перебору, прогрес якого можна дізнатися після натискання . Щоб призупинити процес, кнопка

Для відновлення . Також можна використовувати прямий перебір (наприклад, від aaaaaaaa до zzzzzzzz):

$ ./oclHashcat64.bin hash.txt ?l?l?l?l ?l?l?l?l

І різні модифікації словника і методу прямого перебору, і навіть їх комбінації (про це можна прочитати файлі docs/examples.txt). У моєму випадку швидкість перебору всього словника склала 11 хвилин, тоді як прямий перебір (від aaaaaaaa до zzzzzzzz) тривав близько 40 хвилин. У середньому швидкість роботи GPU (чіп RV710) становила 88,3 млн/с.

Висновки

Незважаючи на безліч різних обмежень і складність розробки софту, GPGPU - майбутнє високопродуктивних настільних комп'ютерів. Але найголовніше — використовувати можливості цієї технології можна прямо зараз, і це стосується не лише Windows-машин, а й Linux.




Розповісти друзям