Многие из новейших суперкомпьютеров основаны на аппаратных ускорителях вычислений (accelerator). включая две самые быстрые системы согласно TOP500 от 11/2013. Ускорители распространяются так же и на обычных PC и даже появляются в портативных устройствах, что ещё больше способствовует росту интереса к программированию ускорителей.
Такое широкое применение ускорителей является результатом их высокой производительности, энергоэффективности и низкой стоимости. Например, если сравнить Xeon E5-2687W и GTX 680, выпущенные в марте 2012, мы увидим, что GTX 680 в четыре раза дешевле, имеет в 8 раз большую производительность операций одинарной точности и в 4 раза большую пропускную способность памяти, а так же обеспечивает более 30 раз большую производительность в пересчёте на доллар и в 6 раз большую производительность на ватт. Исходя из таких сравнительных результатов, ускорители должны бы использоваться везде и всегда. Почему же этого не происходит?
Имеются две главные трудности. Во-первых, ускорители могут эффективно выполнять только определённые классы программ, в частности программы с достаточным параллелизмом, повторным использованием данных, непрерывностью потока управления и структуры доступа к памяти. Во-вторых, писать эффективные программы для ускорителей труднее, чем для обычных CPU по причине архитектурных различий, таких как очень большой параллелизм, открытая (без аппаратных кэшей) иерархия памяти, жёсткость процедуры исполнения и слияние операций доступа к памяти. Поэтому было предложено несколько языков программирования и расширений с тем, чтобы к разной степени скрыть эти аспекты и таким образом сделать программирование ускорителей проще.
Изначальные попытки использовать GPU, которые в настоящее время являются наиболее известным видом ускорителей, для ускорения неграфических приложений были громоздкими и требовали представления вычислений в виде шейдерного кода, который поддерживал ограниченный поток управления и не поддерживал целочисленный операции. Постепенно эти ограничения были сняты, что способствует более широкому распространения вычислений на графических чипах и позволяет специалистам из не графических областей программировать их. Важнейший шаг в этом направлении был предпринят с выходом языка программирования CUDA. Он расширяет C/C++ при помощи дополнительных спецификаторов и ключевых слов, а так же библиотек функций и механизма запуска частей кода, называемых ядра (kernel), GPU.
Скорое принятие CUDA в сочетании с тем, что это проприетарный продукт а так же сложностью написания качественного кода на CUDA, приводит к созданию других подходов к программированию ускорителей, в том числе OpenCL, C++ AMP и OpenACC. OpenCL является непроприетарным двойником CUDA и пользуется поддержкой многих крупных компаний. Он не ограничен только чипами NVidia, а так же поддерживает графические процессоры AMD, многоядерные CPU, MIC (Intel Xeon Phi), DSP и FPGA, что делает его переносимым. Однако, так же как и CUDA, он очень низкоуровневый. Требует от программиста непосредственно управлять перемещением данных, требует непосредственно определять места хранения переменных в иерархии памяти и в ручную реализовывать параллелизм в коде. C++ Accelerated Massive Parallelism (C++ AMP) работает на среднем уровне. Она позволяет описывать параллельные алгоритмы уже на самом С++ и скрывает весь низкоуровневый код от программиста. Оператор «for each» инкапсулирует параллельный код. C++ AMP привязан к Windows, пока ещё не поддерживает CPU и страдает от больших накладных расходов запуска, что делает практически нецелесообразным ускорение с его помощью кода, выполняющегося кратковременно.
OpenACC это уже очень высокоуровневый подход к программированию ускорителей, который позволяет программистам снабдить код директивами сообщив тем самым компилятору какие части кода требуется ускорить, например, отгрузив их на графический процессор. Идея схожа с тем, как OpenMP используется для распараллеливания CPU-программ. На самом деле, предпринимаются усилия к объединению этих двух подходов. OpenACC находится на этапе созревания и в настоящее время поддерживается только немногими компиляторами.
Чтобы понять как в дальнейшем будет развиваться область программирования аппаратных ускорителей стоит изучить как подобный процесс протекал в прошлом с другими аппаратными ускорителями. К примеру, ранние продвинутые PC имели дополнительный процессор — сопроцессор, выполняющий вычисления с плавающей точкой. Позже он был объединён на кристалле с центральным процессором — CPU — и сейчас является его неотъемлемой частью. Они имеют лишь разные регистры и арифметико-логические устройства (АЛУ). Более поздние SIMD-расширения процессоров (MMX, SSE, AltiVec и AVX) не выпускались в виде отдельных чипов, но сейчас они так же полностью интегрированы в ядро процессоров. Так же как и операции с плавающей точкой, SIMD-инструкции вычисляются на отдельных АЛУ и с использованием собственных регистров.
Удивительно, но два этих типа инструкций существенно отличаются с точки зрения программиста. Вещественные типы и операции с ними были давно стандартизированы (IEEE 754) и сегодня используются повсеместно. Они доступны в высокоуровневых языках программирования через обычные арифметические операции и встроенные вещественные типы данных: 32 бит для вещественных чисел одинарной точности и 64 бит для двойной точности. Напротив, не существует никаких стандартов для SIMD-инструкций и само их существование во многом скрыто для программиста. Использование этих инструкций для векторизации вычислений делегировано компилятору. Разработчики, желающие явно использовать эти инструкции должны обращаться к компилятору при помощи специальных не кроссплатформенных макросов.
Поскольку производительность GPU и MIC ускорителей обусловлена их SIMD природой, мы думаем, что их развитие пойдёт путём предыдущих SIMD-ускорителей. Ещё одно сходство с SIMD и ключевая особенность CUDA, сделавшая её успешной, то что CUDA скрывает SIMD сущность, характерную для графических процессоров и позволяет программисту мыслить в терминах потоков, оперирующих скалярными данными, нежели в терминах варпов (warp), оперирующих векторами. Поэтому несомненно, ускорители так же будут перенесены на кристалл с процессором, но мы полагаем, что их программный код не будет достаточно вшит в обычный CPU-код так же как и аппаратные типы данных GPU не будут напрямую доступны программистам.
Некоторые ускорители уже были совмещены на кристалле с традиционными процессорами, это AMD APU (используется в Xbox One), процессоры Intel с интегрированной HD-графикой и Tegra SoC от NVIDIA. Однако, ускорители останутся, вероятно, отдельным ядром потому что трудно объединить их с традиционным процессорным ядром до такой же степени, как это было сделано с математическим сопроцессором и с SIMD-расширениями, то есть, урезать до набора регистров и отдельного АЛУ в составе центрального процессора. В конце концов, ускорители такие быстрые, параллельные и энергоэффективные как раз по причине отличных от CPU архитектурных решений, таких как несвязный кэш, совершенно другая реализация конвейера, память GDDR5 и на порядок большие количество регистров и многопоточность. Следовательно, сложность запуска кода на акселераторах по прежнему останется. Так как даже процессорные ядра, выполненные на одном кристалле как правило имеют общие лишь нижние уровни иерархии памяти, поэтому скорость обмена данными между CPU и акселераторами возможно будет расти, но по-прежнему останется узким местом.
Необходимость явного управления процессами обмена данными между устройствами является существенным источником ошибок и тяжким бременем ложится на программистов. Часто бывает для небольших алгоритмов, что больше кода приходится писать, чтобы организовать обмен данными, нежели сами вычисления над ними. Устранение этого бремени является одним из основных преимуществ высокоуровневых подходов к программированию, таких как C++ AMP и OpenACC. Даже низкоуровневые реализации направлены на решение этой проблемы. К примеру, хорошо отлаженный и унифицированный доступ к памяти это одно из основных улучшений, которые проводятся в последних версиях CUDA, OpenCL и аппаратных решениях NVIDIA GPU. Всё же для достижения хорошей производительности обычно требуется помощь программиста, даже в очень высокоуровневых решениях таких как OpenACC. В частности, выделение памяти в требуемых местах и перенос данных часто нужно выполнять вручную.
К сожалению, все упрощения, предлагаемые такими подходами могут оказаться только частичным решением. Учитывая, что будущие процессоры будут близки к сегодняшним (малым) суперкомпьютерам, вполне вероятно, что в них будет больше ядер, чем сможет обслуживать их общая память. Вместо этого мы думаем, что на каждом кристалле будут кластеры из ядер и каждый кластер будет иметь собственную память, возможно расположенную над этими ядрами в трёхмерном пространстве. Кластеры будут связаны друг с другом сетью выполненной на том же кристалле используя протокол наподобие MPI. И это не так далеко от истины потому что Intel только что анонсировала, что в будущие чипы Xeon будут добавлены сетевые функции, и это шаг в данном направлении. Следовательно, вполне вероятно, что чипы в будущем станут всё более гетерогенными объединяя ядра, оптимизированные по задержкам и пропускной способности; сетевые адаптеры, центры сжатия и кодирования, FPGA и т. д.
Это поднимает крайне важный вопрос о том, как программировать такие устройства. Мы полагаем, что ответ на этот вопрос удивительно схож с тем, как это решено на сегодняшний день для многоядерных CPU, SIMD расширений и существующих на данный момент аппаратных ускорителей. Происходит это на трёх уровнях, которые мы называем библиотеки, инструменты автоматизации и «сделай сам». Библиотеки — простейший подход, основанный на простом вызове функций из библиотеки, которая кем-то уже оптимизирована для ускорителя. Многие современные математические библиотеки относятся к этому классу. Если большинство вычислений программы выполняется в этих библиотечных функциях, то применение этого подхода будет вполне оправдано. Он позволяет нескольким специалистам написать одну хорошую библиотеку для ускорения множества приложений, в которых эта библиотека будет использована.
В C++ AMP и OpenACC используется другой подход — инструменты автоматизации. При таком подходе тяжёлая работа перекладывается на компилятор. Успех его зависит от качества и сложности существующих программных инструментов и, как было сказано, часто требует вмешательства программиста. Тем не менее, большинство программистов могут довольно быстро достичь хороших результатов используя этот подход, который не ограничен только использованием предопределённых функций из библиотек. Это похоже на то, как несколько групп специалистов реализует «внутренности» SQL, что позволяет обычным разработчикам в дальнейшем пользоваться готовым оптимизированным кодом.
Наконец, подход «сделай сам» используется в CUDA и OpenCL. Он даёт программисту полный контроль над доступом почти ко всем ресурсам ускорителя. При хорошей реализации, результирующий код превосходит по производительности любой из двух предыдущих. Но это даётся путём значительных усилий на изучение этого подхода, написания большого количество дополнительного кода и большего простора для возможных ошибок. Всяческие усовершенствования сред разработки и отладки могут смягчить все эти трудности, но только до определённой степени. А значит этот подход полезен в первую очередь для экспертов. Таких, которые как раз занимаются разработкой методов, упомянутых в предыдущих двух подходах.
Из-за простоты использования библиотек, программисты способны использовать его где это только возможно. Но это возможно только если соответствующие библиотечные функции существуют. В популярных областях такие библиотеки обычно существуют. Например, операции с матрицами (BLAS). Но в смежных областях или там, где вычисления не структурированы, реализовать библиотеки ускорители трудно. При отсутствии соответствующих библиотек, программисты выбирают инструменты автоматизации если конечно они достаточно развиты. Вычисления недоступные в виде библиотек, не очень требовательные к производительности и поддерживаемые компилятором, скорее всего реализуются с использованием инструментария автоматизации. В остальных случаях используется метод «сделай сам». Поскольку OpenCL объединяет успешные решения, представленные в CUDA, не является проприетарным и поддерживает разные аппаратные решения, мы думаем, что он или производные от него решения станут преобладающими в этой области подобно тому, как MPI стал стандартом де-факто в программировании систем с распределённой памятью.
Принимая во внимание аппаратные особенности и процесс эволюции, изложенный выше, можно говорить, что будущие процессорные чипы будут содержать множество кластеров с собственной памятью. Каждый кластер будет состоять из многих ядер, при этом не обязательно все ядра будут функционально идентичными. Каждое многопоточное ядро будет состоять из множества вычислительных звеньев (т. е. функциональных блоков или АЛУ) и каждое вычислительное звено будет выполнять SIMD-команды. Даже если будущие чипы не будут включать всё это сразу, все они будут иметь одно ключевое сходство, а именно, иерархию уровней параллелизма. Для создания эффективных и переносимых программ для подобных систем, мы предлагаем то, что мы называем техникой «обширного параллелизма» («copious-parallelism» technique). Она является обобщением того, как MPI-программы адаптируются программистом под разное количество вычислительных узлов или как код OpenMP неявно приспосабливается по разное количество ядер (cores) или потоков (threads).
Основная идея обширного параллелизма и причина такого названия в том, чтобы обеспечить обширные, параметризируемые возможности параллелизма на каждом уровне. Параметризация позволит понизить степень параллелизма программы на любом уровне для приведения её в соответствие со степенью аппаратного параллелизма на этом уровне. К примеру, на системе с разделяемой памятью не требуется самый высокий уровень параллелизма, его надо установить в один «кластер». Аналогично, в ядре, где вычислительные звенья не могут выполнять SIMD-инструкции, параметр, определяющий ширину SIMD должен быть установлен в единицу. Пользуясь подобной техникой можно реализовывать возможности многоядерных CPU, GPU, MIC и других устройств, так же как и вероятных будущих аппаратных архитектур. Писать программы таким образом бесспорно труднее, но обширный параллелизм позволяет при помощи единственной кодовой базы извлечь высокую производительность из широкого класса устройств.
Мы протестировали этот подход на задаче прямого моделировании n-тела. Мы написали единственную реализацию алгоритма с обширным параллелизмом с помощью OpenCL и произвели замеры на четырёх совершенно разных аппаратных архитектурах: NVIDIA GeForce Titan GPU, AMD Radeon 7970 GPU, Intel Xeon E5-2690 CPU и Intel Xeon Phi 5110P MIC. Учитывая, что 54% всех операций с плавающей точкой были не FMA-операциями (FMA — операции умножения с накоплением), обширный параллелизм позволил достичь производительности 75% от теоретической пиковой для NVIDIA Titan, 95% для Radeon, 80,5% для CPU и 80% для MIC. Это только отдельный пример, но результаты его весьма обнадёживающие. На самом деле, мы считаем, что обширный параллелизм будет в течение некоторого времени единственным подходом к написанию переносимых высокопроизводительных программ для существующих и будущих систем на аппаратных ускорителях.
[ Источник ]
9 января 2014
Камиль Роки (Kamil Rocki) и Мартин Бёртшер (Martin Burtscher)
Автор: YouraEnt