Графический процессор
Графи́ческий проце́ссор (ГП; англ. Graphics Processing Unit – блок обработки графики, GPU), встроенный функциональный блок центрального процессора (ЦП) или дискретный ГП – специализированный микропроцессор видеокарты. Современные ГП активно используются почти везде, где необходимы высокопроизводительные вычисления и большая пропускная способность памяти: в научных расчётах, в задачах искусственного интеллекта (где необходимы тензорные вычисления), в компьютерном зрении, обработке изображений, компьютерной графике, криптографии, майнинге криптовалют и многих других областях.
История появления ГП
Графические процессоры появились в середине 1990-х гг. как очень простые ускорители некоторых операций 2D-графики во время, когда 3D-графика только зарождалась, и развивались одновременно с интерфейсами их программирования (англ. Application Programming Interface – API). Развитие ГП происходило не эволюционным, а скорее революционным путём. Именно поэтому программирование ГП в годы активного развития было нетривиальным занятием: каждый год с 1995 по 2004 гг. выходила новая версия интерфейса программирования DirectX от компании Microsoft, не совместимая с предыдущей версией того же интерфейса.
Интерфейс программирования OpenGL
Решение OpenGL от компании Silicon Graphics (SGI) появилось значительно раньше (1992) и во многом было проще и удобнее, чем DirectX. Одним из ограничений как IRIS GL – предшественника OpenGL, так и DirectX было то, что они позволяли использовать только возможности, поддерживаемые оборудованием; если возможность не была реализована аппаратно, приложение не могло её использовать. OpenGL преодолевал эту проблему за счёт программной реализации возможностей, не предоставляемых аппаратно, что позволяло приложениям использовать этот интерфейс на относительно маломощных системах. Кроме того, OpenGL (в отличие от DirectX) всегда разрабатывался как обратно совместимый. Однако из-за распространённости операционной системы Windows, в 1995 – начале 2000-х гг. DirectX являлся основным способом программирования ГП. В 2017 г. консорциум Khronos Group опубликовала последнюю спецификацию OpenGL 4.6, переключившись на разработку принципиально нового API Vulkan, который на 2023 г. является наиболее распространённым и широко поддерживаемым API программирования ГП.
Современные средства программирования ГП
«Современными» можно считать видеокарту GeForce 8800 GTX и появившиеся позднее, т. е. распространившиеся около 2007 г. и позднее. В этот период сформировалась общепринятая модель программирования ГП. Она активно внедрялась компанией Nvidia в своём API программирования CUDA, на котором писать программы было значительно проще, чем на DirectX или OpenGL. Благодаря CUDA начиная с 2007 г. ГП стали активно использоваться для высокопроизводительных научных вычислений и искусственного интеллекта. Однако CUDA до сих пор остаётся закрытой разработкой одного производителя. Его аналог от компании AMD, называемый HIP, как и открытый API OpenCL, очень сильно ограничен в возможностях.
Vulkan, с другой стороны, создавался как API с широкими возможностями. Разработчики игр, как и другие программисты, получали доступ к более низкоуровневым операциям в аппаратуре, а производители аппаратуры могли делать простой и легко поддерживаемый драйвер. Лидеры индустрии начали противостоять конкуренту и выпустили свои API: DirectX12 от Microsoft и Metal от компании Apple, по сути, являются уменьшенными версиями Vulkan, более простыми, но в значительной мере менее кросс-платформенными. Появление Vulkan стало возможным благодаря тому, что архитектура ГП и их возможности стали относительно одинаковыми для всех производителей, поэтому их удалось стандартизировать в одном API. Однако при этом программирование на Vulkan значительно более трудоёмко, чем на других API.
Концепция программирования SPMD: одна программа, множество данных
С точки зрения программиста, ГП выглядят как устройства, выполняющие некоторую изолированную программу, называемую вычислительным ядром. Такое ядро выглядит как один или несколько вложенных циклов, которые некоторым образом обрабатывают данные, например пикселы изображения. Если итерации этого цикла независимы друг от друга (а это очень часто именно так), тогда тело такого цикла можно назвать вычислительным ядром, или т. н. вычислительным шейдером (англ. compute shader). Это тело одинаковым образом обрабатывает все пикселы изображения (Single Program), но работает на самом деле над разными данными (Multiple Data). Более сложные виды параллельных алгоритмов (такие как графический конвейер и конвейер трассировки лучей) не выбиваются из концепции SPMD, но введены в API в виде отдельных элементов из-за неравномерного распределения работы: в графическом конвейере один треугольник может накрыть практически сколь угодно много пикселов, а в трассировке лучей возможна рекурсия и, кроме того, значительно бо́льшая ветвистость алгоритма, что приводит к проблеме расхождения потоков по разным веткам программы. Именно эта проблема решается т. н. вызываемыми шейдерами в конвейере трассировки лучей.
Архитектура ГП
В концепции SPMD важно не столько то, что программа одинакова для всех пикселов: в программе могут присутствовать ветвления, сложные вычисления и т. п.; гораздо более важно, что пикселов достаточно много, а у «сердца» ГП, называемого мультипроцессором, всегда достаточно работы. Чтобы понять, почему это так важно, необходимо рассмотреть механизм конвейерного выполнения инструкций в современных процессорах (см. Параллельная обработка данных). Проблема зависимости (англ. data hazard) в конвейере является фундаментальной проблемой современных вычислительных систем, и она решается в ЦП довольно сложно. В ГП же эта проблема решается очень просто и элегантно: если имеется много потоков/пикселов, каждый такт в конвейер поочерёдно попадают инструкции из разных потоков. Таким образом, для любой длины конвейера N всегда гарантированы ровно N независимых инструкций в конвейере.
Тот же принцип применяется для повышения эффективности работы с памятью. Работы для мультипроцессора всегда должно быть много, чтобы успешно скрывать латентность (время обращения) памяти – несколько сотен тактов, в это время на мультипроцессоре считаются другие потоки. При этом в ГП часто используется приём, называемый объединением запросов к памяти: соседние потоки обращаются к соседним ячейкам массива. Поскольку микросхемы DRAM памяти могут считывать данные только большими блоками, наилучшая ситуация для ГП, когда весь этот блок будет использоваться потоками ГП, причём каждый следующий поток будет считывать следующий элемент массива.
Во многих ГП (например, GeForce GTX 8800) вообще не использовалась кеш-память (кеш) при обращении к видеопамяти, за исключением 2D- и 3D-изображений. Это было просто не нужно, т. к. в ГП успешно скрывалась латентность обращения к памяти вычислениями в других потоках, а объединение запросов к памяти помогало взаимодействовать с микросхемой DRAM эффективно, большими блоками.
Из этого вытекает два вывода.
Для того чтобы указанные принципы работали, данные каждого потока нужно хранить непосредственно на чипе. Именно поэтому у ГП значительно бо́льший регистровый файл, чем у ЦП. Локальные переменные хранятся преимущественно в регистрах, каждый такт ожидая возможности «запуститься» в конвейер.
Кеши ГП работают по другому принципу, нежели кеши ЦП. В ЦП кеш рассчитан на переиспользование данных во времени: если ЦП обратился по какому-то адресу, скорее всего, в ближайшем будущем он будет обращаться к соседним адресам (см. Локальность данных). На ГП кеш работает в основном на разделение данных: если какой-либо поток обратился по адресу, скорее всего, другие потоки будут обращаться к этому или соседним адресам.
Почти любой современный ГП устроен следующим образом. Внутри мультипроцессора располагаются конвейеры. Регистровый файл представляет собой большое множество регистров, хранящих локальные переменные программы, и он отделён от конвейеров. Регистровый файл – это самостоятельный ресурс ГП, даже если определённые регистры и привязаны к конвейерам с некоторыми ограничениями. Возможность разделения регистрового файла на несколько частей внутри мультипроцессора на рисунке изображена путём разделения мультипроцессора на несколько т. н. блоков обработки, но наличие этих блоков не обязательно. Все мультипроцессоры имеют один общий L2 кеш, называемый ещё иногда текстурным кешем. Доступ в память, как правило, идёт через этот кеш, и при этом он достаточно большой, особенно на мобильных ГП. На мобильных устройствах обращение в глобальную память приводит к значительным затратам энергии, поэтому его стремятся минимизировать путём увеличения размера L2 кеша. L1 кеш (один на мультипроцессор) – разделяемая память. Это быстрая память, которую можно использовать для взаимодействия потоков между собой.
Именно отсюда вытекает разбиение потоков на группы в модели SPMD у вычислительных шейдеров: N потоков объединяются в группы с размером M, всего групп будет N/M. Гарантируется, что каждая группа будет выполняться на одном мультипроцессоре (и поэтому потоки внутри группы могут использовать разделяемую память для передачи данных между потоками), но при этом один мультипроцессор может одновременно выполнять множество групп. В графическом конвейере разделяемая память, как правило, используется для передачи данных между стадиями графического конвейера, и поэтому напрямую она недоступна.
Отечественные разработки
Чип MALT (Михеев. 2020), разработанный на физическом факультете МГУ имени М. В. Ломоносова, является по всем признакам настоящим ГП, хотя изначально он разрабатывался как специализированное устройство для криптографии.
К области технологий программирования относятся другие отечественные разработки: DVM (Новые возможности DVM-системы. 2019) и kernel_slicer (kernel_slicer: high-level approach on top of GPU programming API. 2022), разработанные в Институте прикладной математики имени М. В. Келдыша РАН.
Как и OpenMP, DVM-система использует т. н. параллельные директивы и предназначена для расчёта больших задач на сетках. Она умеет автоматически распределять между несколькими ГП данные, не помещающиеся на один ГП. kernel_slicer представляет собой генератор программ, на вход принимающий описание алгоритма на C++, а на выходе дающий реализацию того же алгоритма на Vulkan, решая тем самым проблему высокой сложности программирования на Vulkan API.