Вы зашли как: Гость
19.07.2010 11:21 | deeper2k

Мы рассмотрели аппаратную часть, и теперь примерно понятно, на базе чего работает CUDA. Самое время познакомится с программной частью - каким образом происходит доступ к аппаратным ресурсам, что доступно прикладному программисту, и как вообще строится программа, выполняющаяся на CUDA-совместимом GPU.

Для запуска примеров нам потребуется:

1) видеокарта на чипе NVidia - GeForce 8 и выше;
2) драйвер видеокарты;
3) установленные пакеты CUDA Toolkit и SDK;
4) если вы работаете в MS Windows - то Visual Studio 2005 или 2008 (подойдут также бесплатные Express-версии).

Что касается п. 2 и 3, инсталляторы можно взять здесь: http://www.nvidia.com/cuda. Там же имеется документ Getting Started, в котором описаны шаги по установке и настройке средств разработки. Стоит отметить, что программировать CUDA-устройства можно под любой из современных ОС: поддерживаются MS Windows, Linux и Mac OS.

Обычно приложение, применяющее CUDA, выглядит следующим образом: есть хост-программа, содержащая основной код (пользовательский интерфейс, часть логики и т.д.) и есть код, исполняемый на GPU (его принято называть ядром, от англ. kernel):


Здесь изображен типичный способ применения CUDA: есть обычная хост-программа, выполняющаяся на CPU. В нужный момент времени она формирует набор данных для обработки, и загружает его в память видеоадаптера. После этого хост-приложение запускает процесс обработки, и как только будет получен результат, забирает его из памяти устройства. Очевидно, что при такой схеме функция-ядро и основная программа максимально разобщены, и ядро работает в "песочнице", созданной для него на устройстве.

Каким образом хост-программа взаимодействует с устройством? На данный момент поддерживаются два программных интерфейса: CUDA C и CUDA driver API. Первый из них, CUDA C, представляет собой набор библиотек + небольшую модификацию языка C, и позволяет в достаточной степени абстрагироваться от аппаратной платформы. При этом CUDA driver API предоставляет более низкоуровневый доступ к аппаратуре, однако требует больше усилий для подгрузки и запуска исполняемых модулей CUDA, давая при этом больше возможностей контроля над тем, как будет выполняться код. Мы остановимся на высокоуровневом варианте - CUDA C API (который в конечном итоге всё равно использует тот же driver API).

На данный момент CUDA позволяет прикладному программисту использовать несколько вариантов высокоуровневых языков:


Т.е. прикладная программа может быть написана на CUDA C или CUDA Fortran, например.
Функции-ядра размещаются в файлах специального формата - назовём их сборками. Сборки могут быть написаны, используя набор инструкций, называемый PTX. Однако проще реализовать нужный алгоритм используя знакомый всем синтаксис C и скомпилировать полученный код при помощи nvcc.exe - компилятора, идущего в составе CUDA Toolkit. При этом на выходе можно получить как бинарный результат - т.н. cubin-сборку, так и PTX-сборку. Приложение-хост может использовать обе формы для исполнения на устройстве. Разница между ними заключается в том, что при загрузке PTX-сборки происходит JIT-компиляция (just-in-time, т.е. "на лету") драйвером в двоичную форму, наиболее выгодную для выполнения на данном конкретном GPU. За это приходится платить несколько большим временем при запуске функции-ядра.

На чём можно реализовывать хост-приложение? Здесь полная свобода выбора - от обычного C, заканчивая сравнительно экзотическими вариантами - например, проект PyCUDA позволяет использовать CUDA API из Питона! Всё сводится к созданию обёртки над CUDA API для каждой конкретной платформы - будь то Java, .NET и пр. То есть процесс создания хост-приложения, в сущности, ничем не отличается от написания обычного приложения, использующего исключительно CPU. По-моему, довольно удобно.

Давайте подробнее остановимся на программной модели CUDA и особенностях работы функций-ядер. У нас имеется один или более входных наборов элементов. Имеется алгоритм обработки элемента каждой последовательности - это и есть функция-ядро (kernel). В результате мы получаем также некоторую последовательность элементов. На аппаратном уровне есть набор мультипроцессоров SM, которые способны выполнять ядра. Подход CUDA предлагает нам поступить так - разбить всю исходную задачу на множество мелких подзадач. Например, есть массив чисел, над которыми нужно выполнить некое действие - получим, что каждое число можно обрабатывать параллельно. Каждая нить будет заниматься обработкой одного элемента. Однако, что если нужно как-то синхронизировать процесс обработки каждого элемента с другими? Синхронизация большого числа нитей станет делом накладным. Кроме того, как распределить нити между имеющимися в системе SM?

Мы вплотную подошли к понятию "блок". На самом деле блок - это совокупность мелких подзадач, которые можно обработать в рамках одной нити. Блоки объединяются в гриды (от англ. grid). И уже блок будет выполняться на SM. Нити в пределах заданного блока могут взаимодействовать между собой. Нити из разных блоков синхронизироваться не могут, ибо не гарантируется какой-либо порядок выполнения блоков. Блоки могут быть одно-, двух- и трёхмерными - какие использовать, решает разработчик, однако блоки должны быть все одинакового размера. Поясню на примере - вот как можно разделить простой одномерный массив на блоки:


Исходную последовательность из 12 элементов разделили на 3 блока по 4 элемента. Каждый элемент будет обработан отдельной нитью. А вот как можно разделить входную матрицу:


Т.о. можно выбрать удобный метод деления в зависимости от типа данных, с которыми предстоит работа.

Зачем столько внимания уделяется блокам и принципам их разбиения? Дело в том, что понятие "блок" защищает нас от деталей работы планировщика нитей в SM. Блок (или совокупность блоков) исполняется на SM, при этом происходит разделение нитей внутри блока - нити группируются в т.н. варпы (от англ. warp). В текущей реализации CUDA варп имеет размер 32 нити. Вот как раз нити в пределах одного варпа и выполняются физически параллельно, т.е. одновременно. SM последовательно выполняет варпы на потоковых процессорах. При этом переключение между варпами является достаточно "дешевым" действием - поскольку контекст у нити минимален, он буквально содержит только значение указателя команды (instruction pointer).

Получим следующий интересный эффект: пусть для выполнения очередной команды варпу требуются данные из DRAM - глобальной памяти, распаянной на плате. Обращение к ней довольно медленное, поэтому пока варп ожидает данные, SM обращается к другому варпу и начинает исполнять его нити. Если и этому варпу потребуются данные в DRAM, то SM приступит к выполнению следующего варпа и т.д. до тех пор, пока варпы не закончатся. Если так получится, что все варпы ждут данные, то SM будет бездействовать. Однако, при достаточно большом числе варпов пока SM будет "опрашивать" их все последовательно, данные для первого варпа уже могут успеть прийти из DRAM, и его нити снова будут готовы продолжать выполнение. Здесь и становится очевидной разница в программировании GPGPU и CPU, происходящая из разницы в их строении. Как вы помните, основнуя часть кристалла GPU составляют ALU, в то время как у CPU присутствует большой, многоуровневый и хитрый кэш. Латентность доступа к DRAM в случае CPU покрывает за счёт кэширования данных, а в случае GPU - за счёт большого количества нитей. Потоки на CPU - крупные структуры, переключение между ними долгое. На GPU же напротив - нити очень легковесные, и переключение стоит минимальных затрат времени. Отсюда вывод - чтобы максимально нагрузить GPU, следует подготовить к выполнению как можно большее число нитей. При этом выгодно, конечно, если нити никак не связаны между собой и не требуется синхронизация.

Итак, нити логически делятся на блоки. Блоки - 1D, 2D или 3D-совокупности нитей. Блоки объединяются в гриды. Грид в свою очередь может иметь 1D или 2D структуру. Здесь есть интересный момент - в CUDA C размерность грида задаётся int-полем. При этом в реальности размер грида в текущей реализации умещается в 2 байта, т.е. можно создать грид с максимальным числом блоков = 65536 х 65536. Впрочем, даже такой размерности, по-моему, вполне достаточно для решения любых практических задач. Топология определяется на этапе выполнения, что позволит подстроиться под текущие условия работы и оптимизировать размеры грида и блока.

Что касается синхронизации, то нити могут взаимодействовать между собой посредством shared memory, либо с использованием принципа барьера. Барьерная синхронизация здесь заключается в том, что варпы "засыпают" до тех пор, пока все нити данного блока не подойдут к выполнению определённой команды. Например, варп 1 дошёл до команды К, и его исполнение "замораживается", пока остальные варпы данного блока также не дойдут до этой команды.

Вкратце - это всё, что касается программной модели. Разумеется, есть много областей, которые пока остались неосвещенными - например, типы памяти в CUDA, работа с текстурами, однако для "быстрого старта" рассмотренного материала должно хватить. В следующей части я предлагаю посмотреть на CUDA с точки зрения практики.

Vitus, TheVista.Ru Team,
Июль 2010

Комментарии

Комментариев нет...
Для возможности комментировать войдите в 1 клик через

По теме

Акции MSFT
83.72 0.00
Акции торгуются с 17:30 до 00:00 по Москве
Мы на Facebook
Мы ВКонтакте
Все права принадлежат © MSInsider.ru (ex TheVista.ru), 2017
Сайт является источником уникальной информации о семействе операционных систем Windows и других продуктах Microsoft. Перепечатка материалов возможна только с разрешения редакции.
Работает на WMS 2.34 (Страница создана за 0.036 секунд (Общее время SQL: 0.008 секунд - SQL запросов: 31 - Среднее время SQL: 0.00026 секунд))