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 немного выше, чем OpenCL на чипах Nvidia.
- Благодаря единому производителю (Nvidia), можно однозначно рассчитывать на соответствие документации и реализации CUDA, что не гарантируется для OpenCL.
- OpenCL — это единственный вариант, если вам нужно работать с чем-либо, кроме чипов Nvidia.
Как это работает
Последовательность обработки данных с CUDA
Давайте опишем процесс работы с GPGPU при помощи схемы, представленной на рисунке:
- Формируем в ОЗУ данные, которые необходимо обработать.
- Копируем эти данные в видеопамять.
- Даём GPU задание обработать данные.
- GPU выполняет задачу параллельно на каждом ядре.
- Копируем результат обратно в ОЗУ.
Нужно отметить, что вычисления общего назначения на 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, мы реализуем простое приложение, которое преобразует набор изображений, применяя к ним простой фильтр.
Для сравнения создадим три реализации:
- С использованием стандартной Task Parallel Library из .NET Framework.
- С использованием Alea GPU.
- С использованием ILGPU.
Поскольку обе библиотеки используют CUDA, нам понадобится устройство Nvidia. К счастью, у меня такое имеется.
В общих чертах, мой компьютер имеет следующие характеристики:
- CPU: Intel Core i5-4460 (4 cores no Hyper-Threading, 3.20 GHz base clock speed).
- GPU: Nvidia Geforce GTX 1050 Ti (768 CUDA Cores, 4 GB GDDR5 VRAM, 1290 MHz Clock base clock speed).
- RAM: 32 GB DDR3.
- Накопитель: Samsung SSD 850 EVO 250 GB (что не так уж важно).
- Операционная система: Windows 10 Pro.
Прежде чем продолжить, нам будет нужно установить CUDA Toolkit (нужно для ILGPU, но не для AleaGPU) с официального веб-сайта.
Обе эти библиотеки кроссплатформенны, но поскольку Alea GPU еще не адаптирована для .NET Core, мы создадим консольное приложение на базе Windows, используя последнюю версию .NET Framework, установленную на моем компьютере (а именно 4.7.1).
Нам понадобятся следующие Nuget-пакеты:
- Install-Package Alea — Version 3.0.4
- Install-Package FSharp.Core — Version 4.5.0
- Install-Package ILGPU — Version 0.3.0
- Install-Package SixLabors.ImageSharp — Version 1.0.0-beta0004
Alea GPU требует FSharp.Core, поскольку создана на его основе.
ImageSharp — это отличная кроссплатформенная библиотека обработки изображений, которая упростит нам процесс чтения и сохранения изображений.
Общий алгоритм
Наша программа будет довольно простой и состоит из следующих шагов:
- Загрузка изображения с помощью класса ImageSharp
Image
. - Получение массива пикселей (представленного структурой
Rgba32
). - Преобразование массива пикселей (инвертирование цветов).
- Перезагрузка их в объект
Image
. - Сохранение результата в соответствующем каталоге.
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(); } }
Простой тест на производительность
Наконец, нам нужно измерить скорость преобразований. Для этого я буду использовать стандартный класс Stopwatch
:
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{stopwatch.Elapsed}");
Обратите внимание, что для проведения чистого теста я измерял только время самого преобразования, не принимая во внимание операции ввода-вывода.
Для этого теста я использовал несколько фотографий в высоком разрешении, сделанных телескопом Hubble.
Пример преобразования
Прогнав программу на своем компьютере, я получил следующие усредненные результаты:
- TPL: 0.737472333 секунд;
- Alea GPU: 0.4567708 секунд;
- ILGPU: 0.410849867 секунд.
Таким образом, в этом конкретном случае наименее абстрактный подход, используемый ILGP, оказался самым быстрым и обеспечил почти
Что из этого следует и стоит ли игра свеч?
С одной стороны, не такой уж это большой выигрыш, хотя я вполне уверен, что мой метод использования API был не самым оптимальным.
С другой стороны, это уже хорошо, поскольку мы осуществили преобразования изображений заметно быстрее, а наш CPU при этом оставался свободным для выполнения другой работы!
Заключение
Вычисления общего назначения на GPU с использованием высокоуровневых языков вроде C# — это очень здорово, и я настоятельно рекомендую поиграться с такими библиотеками, как Alea GPU или ILGPU. Я искренне верю, что завтра многие из нас будут программировать в неоднородных вычислительных средах, состоящих из различных типов процессоров, и мы должны научиться использовать их возможности.
Я надеюсь, что встроенная поддержка GPGPU для .NET появится в недалеком будущем. Было бы здорово, если бы Microsoft сделала TPL, совместимым со стандартом OpenCL. Было бы также круто, если бы Microsoft приобрела Alea GPU, как она ранее сделала с Xamarin. Учитывая доступность Nvidia Tesla GPU в Azure это звучит вполне разумно.
Весь исходный код доступен на моем GitHub.
Комментарии и критика приветствуются. Возможно, вы укажете на мои ошибки в использовании API, и это поможет мне получить более значительный прирост в производительности.