Содержание статьи
Поначалу графические процессоры были пригодны для весьма узкого круга задач (угадай, каких), но выглядели очень соблазнительно, и разработчики программного обеспечения решили воспользоваться их мощью, чтобы передать на графические ускорители часть вычислений. Поскольку ГП невозможно использовать таким же образом, как ЦП, понадобились новые инструменты, которые не заставили себя ждать. Так появились CUDA, OpenCL и DirectCompute. Эта новая волна получила имя GPGPU (General-purpose graphics processing units), обозначающее технику использования графического процессора для вычислений общего назначения. Таким образом, для решения весьма общих задач стали использоваться несколько совершенно разных микропроцессоров, что породило название «гетерогенный параллелизм». Собственно, это и есть тема нашего сегодняшнего разговора.
На рынке уже появилось много разных инструментов для вычислений с помощью графического адаптера. Давай посмотрим, какая технология лучше всего подойдет для наших целей. Сразу же определим, каким требованиям она должна отвечать: быть максимально платформонезависимой (в том числе программно и аппаратно), легко сопрягаемой с имеющимся кодом и используемыми инструментами программирования.
CUDA
Итак, CUDA — запатентованная технология GPGPU от NVIDIA, и в этом совсем нет ничего плохого. Для написания Cuda-скриптов используется язык, близкий к C, но со своими ограничениями. В целом — заслуживающая внимания и довольно широко используемся технология. Между тем зависимость от конкретного вендора портит всю картину.
FireStream
FireStream — то же самое, что CUDA, только от AMD, поэтому не будем задерживать пациента.
OpenCL
OpenCL — открытый язык вычислений, свободная, непатентованная технология, весьма интересное зрелище, однако в своей основе это весьма отличный от C язык (хотя разработчики говорят об обратном). В таком случае программисту придется изучать почти полностью новый язык с нестандартными функциями. Это навевает грусть. К тому же, так как не существует стандарта на двоичный код, компилятор от любого вендора имеет полное право генерировать несовместимый код, из чего следует, что на каждой платформе шейдеры придется перекомпилировать, для чего необходимы их исходные коды. Изначально OpenCL был разработан в Apple, ну а сейчас им заведует Khronos Group — так же, как OpenGL.
DirectCompute
DirectCompute — новый модуль DirectX 11, позволяющий осуществлять операции GPGPU. До введения DirectCompute в DirectX присутствовал язык для реализации вычислений на графическом процессоре, но они были завязаны исключительно на графику. Части приложения, использующие DirectCompute, тоже программируются на HLSL, только теперь этот код может служить более общим целям.
Логично предположить, что DirectCompute — детище Microsoft, но развитием его занимаются NVIDIA и AMD. В отличие от OpenCL, DirectCompute имеет стандарт и компилируется в аппаратно независимый байт‑код, что позволяет ему без перекомпиляции выполняться на разном оборудовании. В операционных системах, отличных от Windows и, соответственно, не имеющих поддержки DirectX, для выполнения DirectCompute-кода используется OpenCL. Как я говорил выше, код для DirectCompute пишется на C-подобном языке HLSL, имеющем свои особенности (нестандартные типы данных, функции и прочее).
AMP
Казалось бы, ни одна технология не обещает быть удобной и достаточно продуктивной. Однако Microsoft приготовила еще одну фичу — надстройку для языка C++ — AMP (Accelerated Massive Parallelism — ускоренный массивный параллелизм). И включила ее поддержку в компилятор Visual C++, начиная с версии, вошедшей в Visual Studio 2012 (многие инструменты для работы с параллелизмом появились только в следующей версии студии — Visual Studio 2013).
В общем случае есть два способа распараллеливания программ: по данным и по задачам. При работе с графическим процессором распараллеливание происходит по первому типу, так как ГП имеет большое количество ядер, каждое из которых выполняет расчеты независимо над своим набором данных. Если на ЦП выполняемые задачи принято называть процессами (которые делятся на потоки и так далее), то задачи, выполняемые на ГП, называют нитями (в Windows NT >= 5.1 тоже есть нити, но не будем придираться к определениям). Таким образом, у каждого ядра есть своя нить. AMP позволяет, используя привычные средства программирования, распараллеливать выполнение кода на графические адаптеры, в случае если их несколько. AMP может работать со всеми современными ГП, поддерживающими DirectX 11.
Тем не менее перед запуском кода на ГП его совместимость с AMP лучше заранее проверить, чем мы займемся в следующем разделе.
В отличие от рассмотренных выше тулз для GPGPU, где использованы диалекты C, в AMP используется C++, со всеми его достоинствами: типобезопасностью, исключениями, перегрузкой, шаблонами и прочим.
Отсюда следует, что разработка гетерогенных приложений стала удобнее и продуктивнее. Что особенно важно для чистоты языка, AMP добавляет только два новых ключевых слова к С++, в остальном используются библиотечные средства AMP (шаблонные функции, типы данных и так далее). Благодаря этому AMP на уровне кода совместим с Intel TBB. Плюс к этому Microsoft открыла спецификацию на AMP всем желающим. Таким образом, сторонние разработчики могут не только расширять AMP, но и переносить его на другие программно‑аппаратные платформы, поскольку AMP разработан с заделом на будущее, когда код можно будет исполнять не только на ЦП и графических ускорителях.
AMP и поддерживаемые ускорители
Напишем программу для получения всех устройств, поддерживающих AMP. Она будет выводить их список в консоль, также программа будет выводить значения некоторых важных для AMP свойств ускорителей.
Создай консольный Win32-проект; для подключения AMP не нужны дополнительные танцы с настройкой компилятора, достаточно только подключения заголовочного файла — amp.
и пространства имен — concurrency. Еще нужны заголовки для подключения операций ввода‑вывода — iostream и iomanip. В функции _tmain
мы только установим локаль и вызовем функцию show_all_accelerators
, которая выполнит необходимую работу — выведет список акселераторов и их свойства. Эта функция ничего не должна возвращать, а внутри нее происходят две операции. В первой мы от объекта класса accelerator с помощью статичного метода get_all
получаем вектор, содержащий все доступные ускорители. Второе действие осуществляется с помощью алгоритма for_each
из пространства имен concurrency. Этот алгоритм выполняет лямбду для каждого элемента вектора:
std::for_each(accs.cbegin(), accs.cend(), [=, &n] (const accelerator& a)
Лямбда, соответственно, передается третьим параметром, здесь показан только ее интродуктор, в нем мы указываем компилятору, что объект акселератора, выбранный из вектора, передаем по значению, а инкрементируемую переменную n — по ссылке. Внутри лямбды мы просто выводим некоторые свойства графического адаптера, как то: путь к устройству (имеется в виду шина), выделенная память, подключен ли монитор, находится ли устройство в отладочном режиме, эмулируется ли функциональность (с помощью ЦП), поддерживается или нет двойная точность, поддерживается ли ограниченная двойная точность (если да, в таком случае устройство позволяет осуществлять не полный набор вычислений, определенные операции не поддерживаются). В моем случае (на ноутбуке с двумя видеоадаптерами) вывод программы следующий.
Как видно, кроме двух физических ускорителей, установленных у меня в ноуте, были обнаружены еще три. Разберемся с ними. Software Adapter (REF) — программный адаптер, эмулирующий ГП на ЦП, также называется средством программной отрисовки. Он работает гораздо медленнее аппаратного ГП. Присутствует только в Windows 8. Используется главным образом для отладки приложений. CPU accelerator есть, как в Windows 8, так и в Windows 7. Тоже очень медленный, поскольку работает на ЦП, применяется для отладки. Microsoft Basic Renderer Driver — лучший выбор из эмулируемых ускорителей, также работает на CPU, поставляется в комплекте с Visual Studio 2012 и выше. Он же известен как WARP (Windows Advanced Rasterization Platform). Для рендеринга использует функциональность Direct3D. Повышенная скорость работы по сравнению с другими эмуляторами достигается благодаря применению инструкций SIMD (SSE).
Кроме того, для разработки и отладки C++ AMP приложений рекомендуется использовать ОС Windows 8, и это утверждение я готов аргументировать. Как я говорил выше, во‑первых, это поддержка отладки на эмулируемом ускорителе, поддержка вычислений с двойной точностью, благодаря спецификации WDDM 1.2, увеличенное количество буферов (я про буферы DirectCompute), позволяющие запись (c поддержкой DirectX 11.1). И самое главное — из‑за того, что в Windows 8 во время копирования данных из ускорителя в память ЦП глобальная блокировка ядра не захватывается (в отличие от положения дел на Windows 7), операция копирования происходит быстрее, от чего возрастает общая производительность.
Элементы AMP
AMP состоит из весьма небольшого набора базовых элементов, часть из которых используются в каждом AMP-проекте. Рассмотрим их кратенько.
Мы уже видели объект класса accelerator, представляющий вычислительное устройство. По умолчанию он инициализируется самым подходящим из имеющихся акселераторов. После получения с помощью функции get_all
списка всех присутствующих ускорителей ему методом set_default
можно назначить другой ГП, передав в параметре путь к последнему. Каждый ускоритель (объект класса accelerator) имеет одно или несколько изолированных логических представлений (находящихся в памяти видеоадаптера), в которых производят вычисления нити, относящиеся к данному конкретному ГП.
Объект класса accelerator_view представляет собой своего рода ссылку на ускоритель. Она позволяет более широко работать с объектом: например, у тебя будет возможность обрабатывать исключения TDR — обнаружение тайм‑аута и восстановление (такое исключение происходит, к примеру, если ГП выполняет вычисления дольше двух секунд, при этом в Windows 7, в отличие от Windows 8, исключения TDR нельзя отключить). Если это исключение не обработать и не передать вычисления на другой accelerator_view, тогда восстановить работу можно только перезапуском приложения.
Шаблонный тип array, как и следует из названия, представляет набор данных, предназначенных для вычислений на ГП. Эта коллекция создается в представлении ГП. Чтобы создать коллекцию данного типа, надо передать конструктору два параметра: тип данных и количество объектов данного типа. Можно создать массив разных размерностей (до 128), задается в конструкторе или путем изменения его шаблонного типа extent <
; имеются перегруженные конструкторы; заполнить массив значениями можно как на этапе его создания (в конструкторе), так и после (с помощью метода copy). Для определения позиции элемента в массиве существует специальный шаблонный тип index <
.
Тип array_view относится к типу array так же, как accelerator_view к accelerator, другими словами — представляет собой ссылку. Она может быть кстати, когда не нужно копировать данные из памяти ЦП в память ГП и обратно. Например, коллекция array всегда находится в памяти ГП, то есть в момент ее инициализации данные копируются из ЦП в ГП. С другой стороны, если объявить объект array_view на основе вектора из области ЦП, данные вектора не будут скопированы до момента непосредственной работы с ГП, а эта работа выполняется внутри алгоритма parallel_for_each
.
Таким образом, это единственная точка приложения, где код распараллеливается для выполнения на акселераторе. Код выполняется на том ГП, массив которого передан алгоритму. В первом параметре parallel_for_each
получает объект extent (или размерность) массива объектов, для которых алгоритм выполняет функцию, переданную (вторым параметром) посредством функтора, или лямбды. В соответствии с первым параметром будет запущено такое количество потоков для выполнения. Существует возможность внутри функции или лямбды (aka ядерной функции) вызвать другую функцию, но она должна быть помечена ключевым словом restrict(
. Если алгоритм parallel_for_each
принимает для выполнения функтор (или лямбда‑выражение), то функция или лямбда, на которую он указывает, тоже должна быть помечена данным ключевым словом.
Имеются еще некоторые ограничения на ядерную функцию, например она может захватывать (из внешнего кода) только параметры, передаваемые по ссылке.
В итоге самое медленное место в любом приложении, использующем вычисления на GPU, — это копирование данных из памяти ЦП в память ГП и обратно. Поэтому необходимо это учитывать, и, если вычислений немного, то, скорее всего, будет быстрее их выполнить на CPU.
Применение AMP
Как ты заметил, AMP неразрывно связан с DirectX, однако это совсем не значит, что с помощью AMP можно выполнять только графические вычисления. Тем не менее графика — это наиболее ресурсоемкие вычисления, требующие высокой скорости работы, поэтому наиболее интересные и показательные примеры относятся как раз к ней.
Итак, установим DirectX SDK, выпуск от июня 2010 года (версия, включающая 11-ю версию интерфейсов). Рассмотрим пример для работы с графикой: вращение треугольника, построенного средствами Direct3D 11. Открой проект DXInterOp. Если построить и запустить приложение, то мы увидим следующее изображение, только в динамике.
Файл DXInterOpsPsVs.
содержит вершинный и пиксельный шейдеры, в файле DXInterOp.
, кроме макросов безопасного удаления объектов, объявлена структура двумерной вершины (Vertex2D), используемая на протяжении всей программы. В файле DXInterOp.
находится основной код приложения: создание окна, инициализация графической подсистемы: создание, разрушение устройства Direct3D, загрузка и создание объектов шейдеров, построение треугольника, заливка и перерисовка окна и так далее. Весь этот код использует функциональность Direct3D и потому не является темой нашего сегодняшнего разговора.
В файле ComputeEngine.
находится интересующая нас часть приложения. Класс AMP_compute_engine отвечает за преобразование координат вершин. В его конструкторе создается ссылка на объект accelerator, который представляется устройством Direct3D. Затем этот класс инициализирует объект m_data, который представлен уникальным указателем на одномерный массив вершин (объявленный ранее как Vertex2D). Рабочей лошадкой класса выступает функция run
, в которой в алгоритме parallel_for_each
внутри лямбда‑выражения вычисляется новая позиция координат для поворота треугольника:
parallel_for_each(m_data->extent, [=, &data_ref] (index<1> idx) restrict(amp){ DirectX::XMFLOAT2 pos = data_ref[idx].Pos; data_ref[idx].Pos.y = pos.y * cos(THETA) - pos.x * sin(THETA); data_ref[idx].Pos.x = pos.y * sin(THETA) + pos.x * cos(THETA);});
Обрати внимание на интродуктор лямбды: указывается, что data_ref типа array<
передается по ссылке, а параметром передает объект idx типа index
, этот индекс является номером выполняемой в данный момент нити. Собственно функция run
вызывается прямо перед визуализацией, поэтому работа должна выполняться очень быстро.
Во многом это надуманный пример, поворот объекта вполне можно реализовать в том же потоке, где выполняются действия Direct3D (инициализация, рендеринг и прочие). Однако в этом примере отчетливо видно разделение обязанностей между частями приложения, и вычисление новых координат для вершин происходит очень быстро даже на софтверном ускорителе, запущенном в отладочном режиме.
Блочные алгоритмы
Когда вычисления происходят на GPU, то, в отличие от CPU, в них не используется преимущество ядерного кеша, поскольку ГП очень редко использует данные повторно. С другой стороны, как и ЦП, ГП очень медленно извлекает данные из глобальной памяти. Особенность ГП заключается в том, что чем ближе находятся целевые блоки, тем быстрее происходит обращение к ним. Все‑таки обращение к данным в кеш‑памяти происходит в разы быстрее. И можно настроить алгоритм таким образом, чтобы он чаще обращался к кешу, то есть сохранял и считывал из него данные. Для этого нужно разбить данные на блоки — штука непростая, но может принести существенную пользу в повышении скорости выполнения алгоритма.
В отличие от ЦП, где кеш в большинстве случаев автоматический, в ГП кеш программируемый. Поэтому программист должен сам заботиться о нем. Мы можем определить блоки, в которых будут выполняться нити. При этом необходимо выполнить два предусловия: вместо простого индекса, как в неблочной программе, использовать блочный индекс, плюс воспользоваться программируемым кешем акселератора. За каждой нитью закреплена область памяти в последнем, и, чтобы разместить там переменную, надо перед ее объявлением поставить ключевое слово tile_static, другими словами — указать на использование блочно‑статической памяти. Переменные, помеченные этим ключом, могут использоваться только внутри ядерной функции. Поскольку блочно‑статическая память очень и очень небольшая, в ней обычно сохраняют небольшие части массивов (коллекции array) из глобальной видеопамяти:
tile_static int num[32][32];
Алгоритм parallel_for_each имеет перегруженную версию, которая в качестве первого параметра принимает объект класса tiled_extent — extent, разделенный на блоки в двумерном или трехмерном пространстве. Вот пример:
parallel_for_each(extent<2>(size, size), [=, &input, &output] (index<2> idx) restrict(amp)
В этом примере имеем массив size*size в двумерном пространстве. Когда алгоритму parallel_for_each
первым параметром передается tiled_extent, то лямбде передается объект tiled_index в том же пространстве, что tiled_extent:
parallel_for_each(extent<1>(number_of_threads).tile<_tile_size>(), [=](tiled_index<_tile_size> tidx) restrict(amp)
Внутри лямбды из объекта tiled_index можно получить доступ как к глобальному, так и к локальному индексу с помощью свойств global и local:
const int tid = tidx.local[0];const int globid = tidx.global[0];
Две приведенные выше модификации — это лишь самые явные изменения, которые надо внести в первую очередь при переходе с неблочной на блочную версию кода. Главная же задача программиста при переделке кода — переосмыслить решение и модифицировать ядерную функцию и вызываемый из нее код.
Одна из главных проблем, которая может возникнуть при разработке блочного алгоритма, — это состояние гонок.
Рассмотрим такой случай. Перед обработкой данных внутри лямбды они копируются из коллекции глобальной в коллекцию в блочно‑статической памяти. После этого вызывается алгоритм для обработки коллекции в блочно‑статической памяти. Но если, предположим, заполнение массива происходило в соответствии с индексом нити, то перед обработкой он может быть заполнен не полностью, так как нити выполняются независимо и, дойдя до вызова алгоритма, ни одна нить не в состоянии узнать, выполнилась ли каждая нить, то есть заполнен ли массив полностью. В таком случае перед вызовом алгоритма надо вставить вызов метода wait
объекта класса tile_barrier, который невозможно создать независимо, но можно получить из объекта класса tile_index, переданного в лямбду:
tile_static int num[32][32];num[tidx.local[0]][tidx.local[1]] = arr[tidx.global];tidx.barrier.wait(); // Получаем tile_barrierif (tidx.local == index<2>(0,0)) { num[0][0] = t[0][0] + num[0][1] + num[1][0] + num[1][1];}
Заключение
AMP может быть использован не только из C++, но и из управляемого кода, например на C#. Вдобавок приложения для Windows Store тоже в полной мере используют C++ AMP — гетерогенный параллелизм на графических ускорителях, которые в настоящее время есть не только в ПК, но и в планшетах и смартфонах.
К сожалению, в статье нам удалось посмотреть только на вершину айсберга Microsoft AMP, огромная часть технологии осталась нерассмотренной. Я только обратил на нее твое внимание, дальнейшее постижение и изучение AMP передаю в твои руки. В заключение хочется отметить: в Visual Studio есть не только средства для создания распараллеленного кода, но и средства для его отладки и визуализации выполнения параллельных вычислений, что сегодня мы не успели обсудить.
Вычисления на акселераторах раньше использовались редко из‑за сложности, однако AMP делает гетерогенный параллелизм доступным для широчайших масс программистов.