Java вычисления на видеокарте

Применим возможности видеокарты в вашей Java-программе

Современные видеокарты имеют встроенный графический процессор, который может производит не свойственные для центрального процессора параллельные вычисления, снимая их с него. Графический процессор, он же GPU (Graphical Processing Unit), — это программируемое устройство, которое можно задействовать в вашей программе, чтобы получить существенное повышение производительности для специфических задач, как-то отрисовка графики, и общих вычислений (GPGPU — General-purpose computing on graphics processing units), применяемых в задачах: компьютерного зрения, распознавания речи, машинного обучения и так далее. Возможности применения графики и вычислений ограничиваются разве что вашей фантазией.

Как правило, возможности GPU используют в программах, написанных на С/C++. Стандартная библиотека платформы Java не содержит API для непосредственной работы с графическим ускорителем, однако это не означает, что его нельзя использовать.

В этой статье мы рассмотрим применение OpenGL API для графики и OpenCL API для GPGPU в реализации LWJGL (Lightweight Java Game Library). OpenGL и OpenCL — это кросс-платформенные API, стандартизованные промышленным консорциумом Khronos Group. Java-программы, которые применяют эти API, смогут работать в Windows, Mac OS X и Linux.

LWJGL — это нативная привязка OpenGL, OpenCL, OpenAL и множества вспомогательных широко используемых в компьютерной графике библиотек. Несмотря на Lightweight в названии, возможности версии библиотеки довольно широки, в частности на основе LWJGL разработан движок известнейшей компьютерной игры Minecraft.

Стоит отметить, что для каждой из операционных систем, которую вы планируете поддерживать, все же придется подготовить отдельную сборку. Так как в месте с программой нужно будет поставить нативные библиотеки — привязки, специфичные для конкретной платформы. Однако с этой задачей легко справится сборочный сценарий Maven.

В случае с Linux также нужно убедиться в том, что установлены драйвера видеокарты, аппаратно реализующие OpenGL и OpenCL. В противном случае вычисления будут выполняться на центральном процессоре.

Если ваша программа будет работать на сервере, убедитесь, что он обладает GPU. Планирующим развернуть приложение в облаке в случае AWS нужны специальные Accelerated Computing instances вместо обычных EC2. В случае Azure нужны GPU optimized virtual machine.

Для запуска примеров из этой статьи вам потребуется пакет JDK версии 1.8 и выше, любая Java IDE (например, Eclipse) и Apache Maven для сборки.

Параллельные вычисления

Как применение GPU может ускорить вашу программу? CPU (central processing unit) — универсальное программируемое устройство, оптимизированное для последовательного выполнения команд. В современных CPU в GPU ядер — сотни. Впрочем, эти ядра — другие, они проще, чем ядра CPU, поэтому с помощью GPU написать всю программу не получится.

CPU хорошо подходит для задач наподобие компиляции исходного кода, формирования HTML, разбора XML, JSON. Однако с операциями над матрицами CPU справляется хуже, потому что обрабатывает данные последовательно. GPU позволяет обрабатывать их параллельно, что дает прирост производительности.

Фигура 1. Последовательная и параллельная обработка данных

Шейдеры

Ше́йдер (shader «затеняющий») — это особая программа, предназначенная для исполнения GPU. Шейдеры составляются на одном из специализированных языков программирования, например OpenCL C, GLSL (OpenGL и Vulkan), HLSL (DirectX), Nvidia Cg (CUDA). После этого передаются драйверу видеокарты как скрипт или байт-код SPIR-V, предварительно скомпилированный специальным компилятором. После успешной загрузки шейдера в GPU в него можно передавать параметры из основной программы и считывать результаты, если в этом есть необходимость.

OpenGL, Vulkan API и OpenCL — индустриальные стандарты, внедряемые консорциумом Khronos Group. Эти API поддерживаются подавляющим большинством производителей графического оборудования и операционными системами. Другие API обладают платформенными или аппаратными ограничениями.

Вне зависимости от API программирование с применением GPU выглядит следующим образом.

  1. Основная CPU-программа создает контекст, связывающий ее с графическим драйвером, и среду выполнения шейдеров — шейдерную программу.
  2. Шейдеры загружаются в шейдерную программу.
  3. CPU подготавливает данные, которые будут переданы в шейдерную программу в нужном формате, копирует их в видеопамять или указывает шейдеру адрес в основной памяти для чтения или записи данных.
  4. Шейдерная программа запускается, после чего результаты ее работы отображаются на экране или считываются для дальнейшего использования.

Особенности LWJGL

В стандартной библиотеке Java нет поддержки OpenGL и OpenCL. LWJGL — это набор нативных JNI-привязок (binding) к библиотекам OpenGL, OpenCL, GLFW, Asimp и других. Поэтому программирование с помощью LWJGL не лишено всех особенностей нативного низкоуровневого кода. Программа, применяющая LWJGL, — это нечто среднее между C и Java. В первую очередь это касается управления памятью. В классической Java-программе мы привыкли использовать массивы примитивных типов наподобие new float[32] и структуры данных — коллекции. Сборщик мусора JVM следит за временем жизни массивов и объектов в памяти вместо нас.

Читайте также:  Intel g41 express chipset видеокарта характеристики

Если бы мы создавали JNI-код самостоятельно, без применения LWJGL, наш C/C++ JNI-код читал бы данные из массивов и коллекций. Это не эффективно с точки зрения производительности, потому что требует копирования данных из Java кучи в промежуточные блоки памяти, выделенные через malloc. OpenGL- и OpenCL-функции ждут указатели на области памяти, ArrayList им не подойдет. Более разумно выделять блоки оперативной памяти, которые можно передать нативным функциям, прямо из Java.

Как правило, в JNI для этого применяются классы стандартной библиотеки, наследники — java.nio.Buffer. Используя буферы, мы будем вынуждены управлять памятью вручную, как и в случае с С/С++. Главная библиотека lwjgl.jar содержит функциональность для работы с буферами. В рамках примеров этой статьи нам будет достаточно стекового распределителя памяти. Подробная информация о распределителях памяти LWJGL в документации.

В этом подходе есть свои преимущества, недостатки и особенности. Основным преимуществом является возможность высвободить блок памяти сразу после того, как он перестал быть нужен программе, не дожидаясь сборки мусора. В случае с трехмерными моделями и текстурами это очень кстати, так как они могут занимать довольно много памяти. Своевременное ее освобождение улучшает производительность программы в целом.

Главный недостаток — если вы ошиблись, приготовьтесь к худшему. На привычное Java-программисту исключение вместе со стеком вызовов рассчитывать не приходится — ждите аварийной остановки виртуальной машины Java вместе с crash-dump файлом. Отладка таких программ может быть весьма болезненной.

Основная особенность — если программе не хватает памяти, память, доступную виртуальной машине и задаваемую опциями -xms и -xmx, следует уменьшать, а не увеличивать. Блок памяти, выделенный сборщику мусора, с точки зрения операционной системы уже занят, в независимости от того, хранит JVM в ней данные или нет. Работая с буферами, мы берем память из кучи процесса, а не кучи Java.

Неспециализированные вычисления

Реализуем классический OpenCL пример на Java: перемножим все элементы двух массивов друг с другом.

OpenCL-шейдер выглядит так:

Публичные функции шейдера OpenCL, доступные основной программе, носят название ядер — kernel. В одном шейдере может быть сразу несколько ядер. Код ядра — это операция, которую мы применим к данным параллельно. Метод назван Single instruction, multiple data (SIMD). Это можно сравнить с телом цикла в Java-программе. CPU применяет тело цикла к данным последовательно, тогда как GPU применяет ядро OpenCL сразу к большому количеству данных. В нашем примере — ко всем элементам массивов одновременно.

Как теперь использовать это ядро из Java? К сожалению, потребуется много служебного кода. Чтобы не раздувать объем этой статьи, для работы с OpenCL мы будем использовать несколько служебных классов — оберток. Полный исходный код примеров к статье можно найти в GitHub-репозитории.

Для начала создадим сборочный скрипт — Maven, который скачет LWJGL из центрального репозитория. В сборочном скрипте определим несколько профайлов для разных операционных систем. А в каждом из них — свойство os.family.

Далее добавим Maven-зависимость и укажем classifier:

Тут и применим os.family-свойство. Теперь добавим maven-enforcer-plugin, чтобы профайл для текущей операционной системы выбирался автоматически.

Перейдем к коду программы. Чтобы создать OpenCL-контекст, связывающий нашу программу с драйвером видеокарты, нужно установить устройство, поддерживающее OpenCL. В компьютере их может быть более одного. Например, встроенная в центральный процессор и дискретная видеокарты.

Получить список поддерживаемых устройств OpenCL можно с помощью двух функций — clGetPlatformIds и clGetDeviceIds. В примере будем использовать класс — фасад, предоставляющий простой интерфейс над низкоуровневым API OpenCL — CLRuntime. CLRuntime упрощает инициализацию OpenCL-устройств, контекста, очереди команд и прочих объектов библиотеки OpenCL. Код этого класса слишком велик, чтобы приводить его в статье, с ним можно ознакомится в GitHub-репозитории.

Выберем устройство по умолчанию, как правило, это наша дискретная видеокарта, и создадим OpenCL-контекст. Затем создадим объект — шейдерную программу и загрузим в нее код OpenCL-шейдера.

Читайте также:  Видеокарта msi geforce gtx 1650 lp oc gtx 1650 4gt lp oc

Ввиду простоты ядра зададим шейдер как строковый литерал. Сложные шейдеры лучше хранить в ресурсах. Далее выделим три буфера, два из которых наполним массивами с исходными данными, в третий будем помещать результаты. Для выделения памяти используем утилиту LWJGL — MemoryStack, это стековый распределитель памяти. MemoryStack реализует интерфейс AutoClosable, его можно использовать в try with resource блоке. Когда блок будет завершен, вся память, выделенная стековым распределителем, высвободится. В нашем примере и в подавляющем большинстве случаев достаточно стекового распределителя памяти.

После выделения буферов создадим очередь команд OpenCL и свяжем буферы с контекстом OpenCL. Укажем библиотеке, что исходные данные следует читать из основной оперативной памяти, а результат должен хранится в видеопамяти. После вычислений мы его оттуда копируем в основную память. Найдем нужное нам ядро из шейдера и передадим в него исходные данные и буфер видеопамяти, где будет сохранен результат. Затем запустим ядро, передав в него размер исходных массивов в байтах — то есть пространство индексов (global work size). После того как ядро сработает на GPU, считаем результат в основную оперативную память из видеопамяти и выведем результат на консоль.

OpenCL может записывать результат и в основную оперативную память, что разумно использовать, когда результат не нужен для дальнейших вычислений на GPU. В данном примере я решил продемонстрировать оба подхода.

Как видим, такая программа даже с применением служебных классов много сложнее обыкновенного цикла for. Поэтому применять OpenCL нужно осторожно, отчетливо понимая задачу и выгоды, которые может дать распараллеливание. Если вам просто нужно перемножить элементы двух массивов, то имеет смысл это делать, если их размер крайне велик или умножать нужно большое (тысячи) количество раз. В противном случае затраты на создание контекста не будут оправданы.

Более практичным примером применения OpenCL может служить библиотека линейной алгебры clBLAS или криптографическая библиотека Hashcat.

Трехмерная графика

В рамках одной статьи невозможно описать библиотеку OpenGL. Это, скорее, формат книги. Моя цель — продемонстрировать возможность применения OpenGL в языке программирования Java. Пример из этой статьи можно использовать как каркас для дальнейшего самостоятельного изучения графики и OpenGL в частности. Реализуем классический OpenGL-пример — нарисуем куб.

Подготовка окна и контекста

Перед тем как начать рисовать, создайте окно (Window) и связанный с ним контекст OpenGL. Это не простая задача, реализация зависит от операционной системы. К счастью, существует широко используемая кросс-платформенная библиотека GLFW, которая упрощает этот процесс. GLFW написана на С, в LWJGL есть связка (binding) c GLFW. Ею и воспользуемся. Всю работу с окном поместим в класс Window. Вызов glfwCreateWindow создает окно и OpenGL-контекст, связанный с ним.

Этот класс только подготавливает окно и контекст OpenGL, отрисовка пространства перекладывается на коллбек Renderable, передаваемый параметром в метод show.

Шейдерная программа

Современный (modern) OpenGL (версии 3.0 и выше) использует программируемый конвейер. Блоки команд glBegin . glEnd объявлены устаревшими, и мы не будем их рассматривать в рамках данной статьи. OpenGL позволяет рисовать точками, линиями и треугольниками. Все остальные примитивы и поверхности можно реализовать с помощью треугольников. OpenGL использует отличный от OpenCL язык шейдеров — GLSL (OpenGL Shading Language).

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

За создание шейдерной программы, включая загрузку из ресурсов и компиляцию шейдеров, выделение буферов видеопамяти OpenGL, их привязку к входным аргументам и внешне задаваемым константам, отвечает класс Program. Код Program и сопутствующих классов слишком велик, чтобы включать в статью, его можно просмотреть в GitHub. Рассмотрим вершинный и фрагментные шейдеры, которые мы загрузим в нашу программу.

Вершинный шейдер вычисляет положение точки наблюдателя в пространстве, направление вектора нормали и передает далее по конвейеру команд во фрагментный шейдер. Затем он просто задает координаты вершины в пространстве отсечения, значение присваивается именованному блоку gl_Position.

Фрагментный шейдер вычисляет затенение по Фонгу для одного источника света:

Без затенения модель будет выглядеть на экране кляксой, а не кубом.

Геометрия

Куб — это шесть граней, каждая из которых — квадрат. Квадраты мы можем сформировать из двух треугольников. Чтобы нарисовать куб, нам нужно выполнить следующие действия.

Читайте также:  Nvidia 8600 gt 1440x900

Подготовить массив вершин (VBO — vertex buffer object). Чтобы позиционировать куб в трехмерном пространстве, нужно задать 6 граней. Каждая грань определяется 4 вершинами. Вершина описывается ее декартовыми координатами x, y, z и еще тремя x, y, z, задающими направление вектора нормали к поверхности от этой координаты.

Для определения всего куба потребуется задать 24 вершины. Они передаются в шейдер как входные аргументы vertex_coord и vertex_normal. Их можно передать как два независимых видеобуфера, однако для лучшей производительности рекомендуется упаковать вершины в один массив, где тройка float’ов вектора нормалей следует сразу за тройкой float’ов координат (Interleaved Vertex Data). То есть получаем многомерный массив формата [24][[3][3]] в сплошном блоке памяти. Program.passVertexAttribArray указывает схему разметки, в соответствии с которой они будут переданы в конвейер из видеопамяти.

Чтобы не дублировать вершины для каждого из треугольников, формирующих грани куба, и тем самым сохранить видеопамять, программа задает массив индексов вершин (IBO — index buffer object). Шейдерная программа будет рисовать куб, обходя массив VBO в порядке индексов, хранящихся в массиве IBO, согласно схеме разметки. То есть индекс 0 означает первую шестерку float’ов, 1 — вторую и так далее. Таким образом на одну грань куба нужно 4×6 координат и нормалей, плюс 6 индексов — по три на треугольник. Из двух треугольников мы получим грань куба — то есть квадрат.

Фигура 2. Модель куба OpenGL

Сложные модели, как правило, читают из файлов, геометрия может занимать мегабайты. Для этого примера обойдемся двумя литералами — массивами. Оба должны быть переданы в видеопамять, чтобы OpenGL могла ими воспользоваться.

Создадим VAO — vertex array object и передадим в него координаты вершин, направление векторов нормалей для граней куба и индексы перехода вершин. VAO — это по сути комбинация VBO и IBO с данными модели, которые мы хотим передать конвейеру.

Графическое изображение трехмерных объектов получается путем проекции трехмерного пространства на плоскость. Плоскостью выступает экран монитора. Проекцию строит за нас OpenGL, но перед этим нам нужно задать математические параметры виртуального трехмерного пространства — сцены. Для описания сцены OpenGL использует линейную алгебру, виртуальное пространство задается матрицами 4×4. Описание координатной системы OpenGL и матриц занимает целую статью, с ней можно ознакомится на сайте learnopengl.com. В данном примере используется перспективная проекция, где плоскость ближнего отсечения удалена от центра по оси z на 2, дальнего на 10. Положение остальных плоскостей рассчитывается на основании ширины и высоты области видимости окна, в которое мы выводим изображение.

Нам потребуется передать шейдерной программе OpenGL три матрицы: произведение матриц вида и модели (model-view), обратную ей матрицу нормалей (normal) и матрицу, задающую пространство отсечения MVP (model view projection). Model-view и normal матрицы используются шейдерами для вычисления затенения по Фонгу. Модель повернем на 20 градусов вертикально по оси X и на 45 градусов горизонтально, также перенесем ее от себя по z на 5. Так мы сможем наблюдать куб, а не только его грань.

Modern OpenGL перекладывает работу с матрицами на программиста, в Java с матрицами удобно работать через библиотеку линейной алгебры JOML, специально предназначенную для OpenGL. JOML повторяет API OpenGL старших версий и устаревшего расширения GLU. В C++ для тех же целей применяется сходная библиотека GLM. GLM-код из примеров на С++ легко переносится на JOML. Матрицы передаются шейдерной программе как внешние константы — uniform.

Также зададим оптические свойства материала модели, положение и свойства источника света. Источник света сделаем точечным, отодвинем на себя, немного влево и еще немного вверх от центра. Материал зададим слегка блестящим, имитирующим пластик.

Рисунок 1: Затенение по Фонгу

Посмотрим, как проекцию из трехмерного пространства на плоскость выполнил OpenGL. Затенение по Фонгу посчитали наши шейдеры.

В заключение

Мы рассмотрели использование аппаратно ускоренного API OpenCL и OpenGL в Java с помощью простых примеров. Эта статья только демонстрирует такую возможность, но не описывает теоретические основы трехмерной графики и линейной алгебры. Этот материал можно почерпнуть из специальной литературы. Стоит отметить, что литература в основном ориентирована на язык С++, а не Java. Если статья найдет отклик, более сложные приемы и техники рассмотрим в следующих частях.

Источник

Adblock
detector