GPGPU via C#: короткий огляд

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

З самого початку свого існування GPU був вузькоспеціалізованим пристроєм, призначеним тільки для перетворення і рендеринга переданих йому даних. При цьому потік даних був тільки одностороннім: від CPU до GPU. Проте з моменту виходу Nvidia CUDA (Compute Unified Device Architecture) в 2007 році і OpenCL (Open Computing Language) в 2009, графічні процесори стали доступні для універсальних двонаправлених обчислень (так званих обчислень загального призначення на графічних процесорах або просто GPGPU).

З моєї точки зору, як .NET розробника, отримати доступ до величезної обчислювальної потужності сотень ядер GPU було б приголомшливою можливістю, тому я спробував з'ясувати, яке нинішнє положення справ GPGPU .NET Framework.

Що таке CUDA і OpenCL і в чому різниця між ними

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

Між двома цими технологіями є істотні відмінності.

По-перше, CUDA — це проприетарная система, розроблена і підтримувана лише Nvidia, в той час як OpenCL — це скоріше відкритий стандарт, а не закінчене рішення або конкретна реалізація. Тому CUDA доступний тільки на пристроях Nvidia, в той час як OpenCL може підтримувати будь-який виробник (до речі, чіпи Nvidia також його підтримують).

По-друге, CUDA — це технологія, яка працює тільки з графічним процесором (принаймні, в даний час), а інтерфейс OpenCL може бути реалізований різними пристроями (CPU, GPU, FPGA, ALU і т. д.).

Ці відмінності приводять до очевидних наслідків:

Як це працює

Послідовність обробки даних з CUDA

Давайте опишемо процес роботи з GPGPU за допомогою схеми, представленої на рисунку:

  1. Формуємо в ОЗП дані, які необхідно опрацювати.
  2. Копіюємо ці дані у відеопам'ять.
  3. Даємо GPU завдання опрацювати дані.
  4. GPU виконує завдання паралельно на кожному ядрі.
  5. Копіюємо результат назад в пам'ять.

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

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

Мені здається, це сильно перешкоджає поширенню GPGPU.

GPGPU на платформі .NET

На платформі .NET поки що відсутня вбудована підтримка GPGPU, тому нам доведеться покладатися на сторонні рішення. При цьому є не так вже й багато варіантів, з яких можна вибирати, тому давайте коротко розглянемо доступні альтернативи серед активно розроблюваних проектів. Характерно, що більшість з них засновані саме на Nvidia CUDA, а не OpenCL.

Alea GPU від QuantAlea

Alea GPU — це заснована на CUDA проприетарная бібліотека з безкоштовною і комерційними версіями. Наявність навіть безкоштовної версії дозволяє вам створювати комерційне програмне забезпечення, готове до взаємодії з GPU, для відеокарт споживчого рівня (серії Nvidia GeForce).

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

Крім того, бібліотека кроссплатформенна і сумісна з .NET Framework і Mono.

Hybridizer від Atimesh

Hybridizer — ще одна заснована на CUDA комерційна бібліотека, але її важко порівняти з Alea GPU з точки зору зручності використання. По-перше, вона безкоштовна тільки для використання в освітніх цілях (при цьому все одно вимагає ліцензію). По-друге, конфігурація вкрай незручна, оскільки вимагає створення проекту на C++, що містить генерується бібліотекою код, який при цьому можна побудувати тільки в Visual Studio 2015.

ILGPU від Marcel K?ster

ILGPU — це бібліотека з відкритим вихідним кодом на основі CUDA, з хорошою документацією та прикладами. Вона не так абстрактна і проста у використанні, як Alea GPU, але тим не менш це вражаючий і серйозний продукт, хоча він і розроблений всього однією людиною. Бібліотека сумісна як з .NET Framework, так і з .NET Core.

Campy від Ken Domino

Campy — ще один цікавий приклад бібліотеки з відкритим вихідним кодом, розробленої одним програмістом. Поки що це ще рання бета-версія, але вона обіцяє максимально абстрактний API. Створена на .NET Core.

Я спробував використовувати в роботі кожне з наведених рішень, але Hybridizer виявилося дуже незручно конфігурувати, в той час як Campy просто не працював на моєму обладнанні. Тому ми буде проводити оцінювання за допомогою бібліотек Alea GPU і ILGPU.

Оцінювання

Щоб отримати уявлення про GPGPU .NET, ми реалізуємо просте додаток, що перетворює набір зображень, застосовуючи до них простий фільтр.

Для порівняння створимо три реалізації:

  1. З використанням стандартної Task Parallel Library .NET Framework.
  2. З використанням Alea GPU.
  3. З використанням ILGPU.

Оскільки обидві бібліотеки використовують CUDA, нам знадобиться пристрій Nvidia. На щастя, у мене таке є.

В загальних рисах, мій комп'ютер має наступні характеристики:

Перш ніж продовжити, нам буде потрібно встановити CUDA Toolkit (потрібно для ILGPU, але не для AleaGPU) з офіційного веб-сайту .

Обидві ці бібліотеки кроссплатформенны, але оскільки Alea GPU ще не адаптована для .NET Core, ми створимо консольний додаток на базі Windows, використовуючи останню версію .NET Framework, встановлену на моєму комп'ютері (а саме 4.7.1).

Нам знадобляться наступні Nuget-пакети:

Alea GPU вимагає FSharp.Core, оскільки створена на його основі.

ImageSharp — це відмінна платформна бібліотека обробки зображень, яка спростить нам процес читання і збереження зображень.

Загальний алгоритм

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

  1. Завантаження зображення з допомогою класу ImageSharp Image.
  2. Отримання масиву пікселів (представленого структурою Rgba32).
  3. Перетворення масиву пікселів (інвертування кольорів).
  4. Перезавантаження їх в об'єкт Image.
  5. Збереження результату у відповідному каталозі.
Image<Rgba32> image = Image.Load(imagePath);
Rgba32[] pixelArray = new Rgba32[image.Height * image.Width];
image.SavePixelData(pixelArray);

string imageTitle = Path.GetFileName(imagePath);

Rgba32[] transformedPixels = transform(pixelArray);

Image<Rgba32> res = Image.LoadPixelData(
 config: Configuration.Default,
 data: transformedPixels,
 width: image.Width,
 height: image.Height);

res.Save(Path.Combine(outDir, $"{imageTitle}.{tech}.bmp"));

transform — це функція наступній сигнатури: Func<Rgba32[], Rgba32[]>.

Ми зробимо реалізацію цієї функції окремо для кожної обраної технології.

Реалізація TPL

Task Parallel Library є стандартним і зручним способом роботи з багатопотоковою кодом .NET Framework. Наведений нижче код реалізує простий фільтр зображень і навряд чи потребує коментарів. Мушу зазначити, що я зраджую масив пікселів, переданий методом Apply, щоб отримати кращу продуктивність, хоча зазвичай я не схвалюю такі функції.

public static class TplImageFilter
{
 public static Rgba32[] Apply(Rgba32[] pixelArray, Func<Rgba32, Rgba32> filter)
{
 Parallel.For(0, pixelArray.Length, i => pixelArray[i] = filter(pixelArray[i]));

 return pixelArray;
}

 public static Rgba32 Invert(Rgba32 color)
{
 return new Rgba32(
 r: (byte)~color.R,
 g: (byte)~color.G,
 b: (byte)~color.B,
 a: (byte)~color.A);
}
}

Реалізація Alea GPU

Беручи до уваги наведену нижче реалізацію фільтра в Alea GPU, слід визнати, що в коді немає суттєвої різниці з попереднім прикладом на TPL. Єдине помітне відмінність — це метод Invert, де нам довелося використовувати конструктор без параметрів для структури Rgba32, таке поточне обмеження коду, що виконується Alea GPU.

public class AleaGpuImageFilter
{
 public static Rgba32[] Apply(Rgba32[] pixelArray, Func<Rgba32, Rgba32> filter)
{
 Gpu gpu = Gpu.Default;

 gpu.For(0, pixelArray.Length, i => pixelArray[i] = filter(pixelArray[i]));

 return pixelArray;
}

 public static Rgba32 Invert(Rgba32 from)
{
 /* Noticeable that Alea GPU only support parameterless constructors */
 var to = new Rgba32
{
 A = (byte)~from.A,
 R = (byte)~from.R,
 G = (byte)~from.G,
 B = (byte)~from.B
};

 return to;
}
}

Реалізація ILGPU

Порівняно з попередніми прикладами, ILGPU API набагато менш абстрактний. По-перше, ми повинні безпосередньо вибирати цільове обчислювальний пристрій. По-друге, нам потрібно явно завантажувати функцію ядра (kernel, чиста статична функція), яка буде виконуватися ядрами GPU для перетворення наших даних. Функція ядра дуже обмежена: вона не може маніпулювати ссылочными типами і, природно, не може виконувати операції введення-виведення. По-третє, нам потрібно явно виділити пам'ять в GPU RAM і завантажити в неї наші дані до запуску процесу перетворень.

public class IlGpuFilter : IDisposable
{
 private readonly Accelerator gpu;
 private readonly Action<Index, ArrayView<Rgba32>> kernel;

 public IlGpuFilter()
{
 this.gpu = Accelerator.Create(
 new Context(),
 Accelerator.Accelerators.First(a => a.AcceleratorType == AcceleratorType.Cuda));
 this.kernel =
 this.gpu.LoadAutoGroupedStreamKernel<Index, ArrayView<Rgba32>>(ApplyKernel);
}

 private static void ApplyKernel(
 Index index, /* The global thread index (1D in this case) */
 ArrayView<Rgba32> pixelArray /* A view to a chunk of memory (1D in this case)*/)
{
 pixelArray[index] = Invert(pixelArray[index]);
}

 public Rgba32[] Apply(Rgba32[] pixelArray, Func<Rgba32, Rgba32> filter)
{
 using (MemoryBuffer<Rgba32> buffer = this.gpu.Allocate<Rgba32>(pixelArray.Length))
{
 buffer.CopyFrom(pixelArray, 0, Index.Zero, pixelArray.Length);

 this.kernel(buffer.Length, buffer.View);

 // Wait for the kernel to finish...
this.gpu.Synchronize();

 return buffer.GetAsArray();
}
}

 public static Rgba32 Invert(Rgba32 color)
{
 return new Rgba32(
 r: (byte)~color.R,
 g: (byte)~color.G,
 b: (byte)~color.B,
 a: (byte)~color.A);
}

 public void Dispose()
{
this.gpu?.Dispose();
}
}

Простий тест на продуктивність

Нарешті, нам потрібно виміряти швидкість перетворень. Для цього я буду використовувати стандартний клас Секундомір:

var stopwatch = new Stopwatch();

foreach (string imagePath in imagePaths)
{
 /* Some Code */
stopwatch.Start();
 Rgba32[] transformedPixels = transform(pixelArray);
stopwatch.Stop();
 /* Some Code */
}

Console.WriteLine($"{tech}:\t\t{секундомір.Elapsed}");

Зверніть увагу, що для проведення чистого тесту я вимірював тільки час самого перетворення, не беручи до уваги операції вводу-виводу.

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

Приклад перетворення

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

Таким чином, у цьому конкретному випадку найменш абстрактний підхід, використовуваний ILGP, виявився самим швидким і забезпечив майже 80-процентний виграш в продуктивності.

Що з цього випливає і чи варта гра свічок?

З одного боку, не такий вже це великий виграш, хоча я цілком впевнений, що мій метод використання API був не самим оптимальним.

З іншого боку, це вже добре, оскільки ми здійснили перетворення зображень помітно швидше, а наш CPU при цьому залишався вільним для виконання іншої роботи!

Висновок

Обчислення загального призначення на GPU з використанням високорівневих мов зразок C# — це дуже здорово, і я настійно рекомендую погратися з такими бібліотеками, як Alea GPU або ILGPU. Я щиро вірю, що завтра багато хто з нас будуть програмувати в неоднорідних обчислювальних середовищах, що складаються з різних типів процесорів, і ми повинні навчитися використовувати їх можливості.

Я сподіваюся, що вбудована підтримка GPGPU .NET з'явиться в недалекому майбутньому. Було б здорово, якби Microsoft зробила TPL, сумісним зі стандартом OpenCL. Було б круто, якби Microsoft придбала Alea GPU, як вона раніше зробила з Xamarin. Враховуючи доступність Nvidia Tesla GPU в Azure це звучить цілком розумно.

Весь вихідний код доступний на моєму GitHub .

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

Опубліковано: 14/09/18 @ 07:50
Розділ Різне

Рекомендуємо:

Raspberry Pi — іграшка для pet-проекту або мікрокомп'ютер для highload продукту
Пожежна команда і біг на випередження: як ми будуємо Java Competence Center в EPAM
Український математик Богдан Рубльов – про олімпіади, перемоги школярів на міжнародних конкурсах та майбутнє математиків
Go дайджест #5: Go 1.11 c підтримкою модулів і WebAssembly, відмовостійкість в архітектурі микросервисной
PHP дайжест #16: новинки в РНР 7.3, Laravel 5.7, головні події цього місяця