Большинство программистов отлично разбираются в работе процессоров и последовательном программировании, поскольку с самого начала пишут код для CPU. Однако многие из них меньше знают о том, как устроены графические процессоры (GPU) и в чем заключается их уникальность. За последнее десятилетие GPU стали чрезвычайно важны благодаря широкому применению в глубоком обучении, и сегодня каждому разработчику необходимо обладать базовыми знаниями о том, как они работают. Цель этой статьи — дать вам это понимание. 

При написании этой статьи я во многом опирался на книгу «Программирование массивно-параллельных процессоров», 4-е издание, авторов Kirk David и Hwu Wen-Mei W. Поскольку в книге рассматриваются графические процессоры Nvidia, я буду говорить о них и использовать специфическую терминологию Nvidia. Однако основные концепции и подходы к программированию на GPU применимы и к другим производителям.

black and white box fan

Сравнение CPU и GPU

Чтобы лучше понять особенности работы GPU, начнём с сравнения центральных процессоров (CPU) и графических процессоров (GPU). Это довольно обширная тема, охватить всё в рамках одной статьи не получится, поэтому остановимся на нескольких ключевых моментах.

Главное различие между CPU и GPU заключается в их целевых задачах при проектировании. CPU разрабатывались для выполнения последовательных инструкций. Для повышения производительности в последовательном исполнении на протяжении многих лет в архитектуру CPU было введено множество улучшений. Основное внимание уделялось уменьшению задержки выполнения инструкций, чтобы процессоры могли выполнять последовательные команды как можно быстрее. Это включает в себя такие функции, как вычислительный конвейер, внеочередное исполнение, спекулятивное исполнение и многоуровневые кэши (это лишь некоторые из них).

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

Рассмотрим конкретный пример. Благодаря низкой задержке выполнения инструкций CPU может сложить два числа гораздо быстрее, чем GPU. В последовательном выполнении нескольких таких операций CPU действительно окажется быстрее. Однако, когда речь идёт о миллионах или миллиардах подобных вычислений, GPU справляется с ними гораздо быстрее, чем CPU, благодаря своему огромному параллелизму.

Если вы любите цифры, давайте поговорим о них. Производительность оборудования для численных вычислений измеряется количеством операций с плавающей точкой, которые оно может выполнить за секунду (FLOPS). Nvidia Ampere A100 обеспечивает пропускную способность в 19,5 TFLOPS при 32-битной точности. Для сравнения, пропускная способность 24-ядерного процессора Intel составляет 0,66 TFLOPS при той же 32-битной точности (эти данные взяты за 2021 год). Причём разрыв в производительности между GPU и CPU продолжает увеличиваться с каждым годом.

На схеме ниже представлено сравнение архитектуры CPU и GPU.

Figure 1: A comparison of the CPU and GPU chip design. Figure from the Nvidia CUDA C++ Programming Guide
Рисунок 1: Сравнение архитектур CPU и GPU. Изображение из Руководства по программированию Nvidia CUDA C++

Как можно заметить, CPU уделяют значительную часть площади чипа функциям, которые уменьшают задержку выполнения инструкций, таким как большие кэши, меньшее количество арифметико-логических устройств (ALU) и большее количество блоков управления. В отличие от них, GPU используют большое количество ALU для максимизации своей вычислительной мощности и пропускной способности. Они занимают минимальную часть площади чипа под кэши и блоки управления — те элементы, которые уменьшают задержку у CPU.

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

Вы можете задаться вопросом: как GPU справляются с большими задержками и при этом обеспечивают высокую производительность? Это возможно благодаря большому количеству потоков и огромной вычислительной мощности, которыми обладают GPU. Даже если отдельные инструкции имеют большую задержку, GPU эффективно распределяют выполнение потоков так, чтобы использовать вычислительную мощность в каждый момент времени. Например, когда некоторые потоки ожидают результата выполнения инструкции, GPU переключается на выполнение других, не находящихся в режиме ожидания потоков. Это гарантирует, что вычислительные блоки GPU работают на максимальной мощности постоянно, обеспечивая высокую пропускную способность. Мы получим более чёткое представление об этом позже, когда будем говорить о том, как ядро выполняется на GPU.

Архитектура GPU

Итак, мы понимаем, что GPU ориентированы на высокую пропускную способность, но как выглядит их архитектура, которая позволяет этого добиться? Давайте рассмотрим это в следующем разделе.

Архитектура вычислений GPU

GPU состоит из массива потоковых мультипроцессоров (Streaming Multiprocessor, SM). Каждый из этих SM, в свою очередь, включает несколько потоковых процессоров, также называемых ядрами или потоками. Например, GPU Nvidia H100 содержит 132 SM с 64 ядрами на каждый SM, что в сумме даёт внушительные 8448 ядер.

Каждый SM имеет ограниченное количество встроенной памяти, часто называемой общей или сверхоперативной памятью (scratchpad memory), которая используется всеми ядрами совместно. Также ресурсы блока управления на SM разделяются между всеми ядрами. Кроме того, каждый SM оснащен аппаратными планировщиками потоков для выполнения потоков.

Помимо этого, каждый SM содержит несколько функциональных блоков или других ускоренных вычислительных устройств, таких как тензорные ядра или блоки трассировки лучей (RT-ядра, Ray Tracing Core), которые обслуживают конкретные вычислительные задачи, для которых используется GPU.

Figure 2: The GPU Compute Architecture
Рисунок 2: Архитектура вычислений GPU

Далее давайте разберём память GPU и заглянем внутрь.

Архитектура памяти GPU

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

Figure 3: The GPU Memory Architecture from the Cornell Virtual Workshop on Understanding GPUs
Рисунок 3: Архитектура памяти GPU (из Корнеллского виртуального воркшопа по изучению GPU)

Давайте разберёмся.

  • Регистры: Начнём с регистров. Каждый SM в GPU содержит большое количество регистров. Например, модели Nvidia A100 и H100 имеют по 65 536 регистров на один SM. Эти регистры распределяются между ядрами динамически, в зависимости от потребностей потоков. Во время выполнения регистры, выделенные для потока, являются частными для него, то есть другие потоки не могут читать или записывать данные в эти регистры.

  • Кэши констант: Далее идут кэши констант на чипе. Они используются для кэширования постоянных данных, используемых кодом, выполняемым на SM. Чтобы воспользоваться этими кэшами, необходимо явно объявить объекты как константы в коде, чтобы GPU мог кэшировать их и хранить в кэше констант.

  • Общая память: Каждый SM также имеет общую (shared memory) или сверхоперативную память (scratchpad memory) — небольшую объёмную, быструю и низколатентную (с низкой задержкой) программируемую память SRAM на чипе. Она предназначена для совместного использования блоком потоков, работающих на одном SM. Идея общей памяти заключается в том, что если нескольким потокам нужно работать с одними и теми же данными, только один из них должен загружать их из глобальной памяти, а остальные будут делиться ими. Грамотное использование общей памяти может сократить количество избыточных операций загрузки из глобальной памяти и улучшить производительность выполнения ядра. Ещё одно применение общей памяти — это синхронизация между потоками, выполняемыми в одном блоке.

  • Кэш L1: Каждый SM также оснащен кэшем L1, который может кэшировать часто используемые данные из кэша L2.

  • Кэш L2: Кэш L2 разделяется всеми SM. Он хранит часто запрашиваемые данные из глобальной памяти, чтобы сократить время задержки. Важно отметить, что кэши L1 и L2 прозрачны для SM, то есть SM не знает, из какого кэша — L1 или L2 — он получает данные. С точки зрения SM, данные поступают из глобальной памяти. Это аналогично тому, как работают кэши L1/L2/L3 в CPU.

  • Глобальная память: GPU также имеет внечиповую глобальную память, которая представляет собой высокоёмкую и высокоскоростную память DRAM (Dynamic Random Access Memory — динамическая память с произвольным доступом). Например, Nvidia H100 оснащена 80 ГБ памяти с высокой пропускной способностью (HBM) и скоростью передачи данных 3000 ГБ/сек. Из-за значительного расстояния от SM задержка глобальной памяти довольно высока. Однако несколько дополнительных уровней памяти на чипе и большое количество вычислительных блоков помогают скрыть эту задержку.

Итак, мы разобрались с ключевыми компонентами аппаратного обеспечения GPU. Теперь давайте углубимся в тему и рассмотрим, как эти компоненты участвуют в выполнении кода.

Модель выполнения на GPU

Чтобы понять, как GPU выполняет ядро (kernel), сначала нужно понять, что такое ядро и какие у него конфигурации. Начнём с этого.

Краткое введение в CUDA-ядра и блоки потоков

CUDA — это интерфейс программирования от Nvidia для написания программ для их GPU. В CUDA вы описываете вычисления, которые хотите выполнить на GPU, в форме, похожей на функцию C/C++, и эта функция называется ядром (kernel). Ядро выполняет операции над векторами чисел параллельно, передавая их в качестве параметров функции. Простой пример — ядро для выполнения сложения векторов, то есть ядро, которое принимает два вектора чисел на входе, складывает их поэлементно и записывает результат в третий вектор.

Для выполнения ядра на GPU необходимо запустить множество потоков, которые в совокупности называются сетью потоков (grid). Однако структура сети более сложна. Сеть состоит из одного или нескольких блоков потоков (иногда их просто называют блоками), и каждый блок состоит из одного или нескольких потоков.

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

Grid of thread blocks (figure from Nvidia CUDA C++ Programming Guide)
Рисунок 4: Сеть блоков потоков (рисунок из Руководства по программированию Nvidia CUDA C++)

Что касается реализации, то написание ядра требует двух частей. Первая — это код на хосте, который выполняется на CPU. Здесь мы загружаем данные, выделяем память на GPU и запускаем ядро с настроенной сетью потоков. Вторая часть — это написание кода для устройства (GPU), который выполняется непосредственно на GPU.

В качестве примера добавления векторов — на рисунке ниже показан код на хосте.

Host code for the CUDA kernel for adding two vectors
Рисунок 5: Код хоста для CUDA-ядра, выполняющего сложение двух векторов.

Ниже представлен код устройства, который содержит определение функции самого ядра.

Device code containing the definition of the vector addition kernel
Рисунок 6: Код устройства, содержащий определение ядра сложения векторов.

Обучение работе с CUDA не является целью данной статьи, поэтому мы не будем углубляться в этот код. Теперь давайте рассмотрим точные шаги выполнения ядра на GPU.

Шаги выполнения ядра на GPU

1. Копирование данных с хоста на устройство

Прежде чем ядро будет запущено на выполнение, все необходимые ему данные должны быть скопированы из памяти хоста (CPU) в глобальную память GPU (устройства). Однако в последних версиях аппаратного обеспечения GPU можно также напрямую считывать данные из памяти хоста, используя унифицированную виртуальную память (см. раздел 2.2 статьи: «Эффективный доступ к памяти для обхода графов вне памяти на GPU»).

2. Назначение блоков потоков на мультипроцессоры (SM)

После того как все необходимые данные загружены в память GPU, он назначает блоки потоков на мультипроцессоры (SM). Все потоки в пределах одного блока обрабатываются одним и тем же мультипроцессором одновременно. Чтобы это произошло, GPU должен зарезервировать ресурсы на SM для этих потоков до того, как сможет начать их выполнение. На практике несколько блоков потоков могут быть назначены одному и тому же SM для одновременного выполнения.

Assignment of a thread block to an SM
Рисунок 7: Назначение блока потоков на SM

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

3. Единая инструкция для множества потоков (SIMT) и warp-ы

Как мы знаем, все потоки одного блока назначаются на один и тот же мультипроцессор (SM). Однако после этого происходит ещё одно деление потоков. Эти потоки группируются в наборы по 32 потока (warp), которые назначаются для совместного выполнения на наборе ядер, называемом вычислительным блоком.

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

Эта модель выполнения warp-а также называется SIMT (single instruction multiple threads) — то есть несколько потоков выполняют одну и ту же инструкцию. Это похоже на SIMD (single instruction multiple data — одиночный поток команд, множественный поток данных) в процессорах.

В более новых поколениях GPU, начиная с архитектуры Volta, доступен альтернативный механизм планирования инструкций, известный как независимое планирование потоков. Он позволяет реализовать полную параллельность между потоками, независимо от warp-а. Этот механизм может использоваться для более эффективного использования ресурсов выполнения или же как средство синхронизации между потоками. В этой статье мы не будем рассматривать независимое планирование потоков, но вы можете прочитать об этом в руководстве по программированию CUDA.

4. Планирование warp-ов и толерантность к задержкам

Есть несколько интересных деталей касательно работы warp-ы, которые стоит обсудить.
Даже если все вычислительные блоки (группы ядер) внутри SM обрабатывают warp-ы, лишь несколько из них активно выполняют инструкции в любой момент времени. Это происходит потому, что в SM доступно ограниченное количество исполнительных блоков.

Однако некоторые инструкции требуют больше времени на выполнение, из-за чего warp вынужден ожидать результат. В таких случаях SM «усыпляет» этот warp и начинает выполнять другой warp, которому не нужно ничего ждать. Это позволяет GPU максимально эффективно использовать все доступные вычислительные ресурсы и обеспечивать высокую производительность.

Планирование без накладных расходов: Поскольку у каждого потока в каждом warp-е есть собственный набор регистров, для SM не требуется дополнительных затрат на переключение с выполнения одного warp-а на другой.

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

5. Копирование результата из памяти устройства в память хоста

Наконец, когда все потоки ядра завершили выполнение, последним шагом является копирование результата обратно в память хоста.

Мы рассмотрели все аспекты типичного выполнения ядра, однако есть ещё один момент, который требует отдельного обсуждения: динамическое разделение ресурсов.

Разделение ресурсов и концепция загруженности 

Использование ресурсов GPU измеряется с помощью метрики «загруженность» (occupancy), которая представляет собой соотношение числа warp-ов, назначенных на SM, к максимальному количеству warp-ов, которое он может поддерживать. Чтобы достичь максимальной производительности, нам хотелось бы иметь 100% загруженность. Однако на практике это не всегда возможно из-за различных ограничений.

Почему же не всегда удается достичь 100% загруженности? SM имеет фиксированный набор ресурсов для выполнения, включая регистры, разделяемую память, слоты для блоков потоков и слоты для потоков. Эти ресурсы динамически распределяются между потоками в зависимости от их потребностей и пределов возможностей GPU. Например, на Nvidia H100 каждый SM может обрабатывать 32 блока, 64 warp-а (т.е. 2048 потоков) и 1024 потока на блок. Если мы запускаем сетку с размером блока в 1024 потока, GPU разделит доступные 2048 слотов потоков на 2 блока.

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

Теперь рассмотрим пример, чтобы увидеть, как распределение ресурсов может повлиять на загруженность SM. Если мы используем размер блока в 32 потока и нам требуется всего 2048 потоков, у нас будет 64 таких блока. Однако каждый SM может одновременно обрабатывать только 32 блока. Таким образом, хотя SM может работать с 2048 потоками, в реальности он будет выполнять только 1024 потока одновременно, что приведёт к загруженности всего в 50%.

Аналогично, каждый SM имеет 65 536 регистров. Чтобы одновременно выполнять 2048 потока, каждому потоку может быть выделено максимум 32 регистра (65 536 / 2048 = 32). Если ядру требуется 64 регистра на поток, мы можем запустить только 1024 потока на SM, что снова приведёт к загрузке в 50%.

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

Эффективное создание ядер GPU — сложная задача. Необходимо грамотно распределять ресурсы, чтобы поддерживать высокую загрузку и минимизировать задержки. Например, использование большого количества регистров может ускорить выполнение кода, но снизить загрузку, поэтому важна тщательная оптимизация кода.

Резюме

Понимаю, что разобраться во множестве новых терминов и концепций может быть сложно. Давайте подытожим основные моменты.

  • GPU состоит из нескольких потоковых мультипроцессоров (SM), каждый из которых имеет несколько вычислительных ядер.

  • Есть глобальная память вне чипа, которая представлена HBM или DRAM. Она расположена далеко от SM на чипе и имеет большую задержку.

  • Есть кэш L2 вне чипа и кэш L1 на чипе. Эти кэши работают аналогично тому, как работают кэши L1/L2 в процессорах (CPU).

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

  • Каждый SM имеет большое количество регистров, которые распределяются между потоками в зависимости от их потребностей. Например, Nvidia H100 имеет 65 536 регистров на SM.

  • Чтобы запустить ядро на GPU, мы создаём сетку потоков. Сетка состоит из одного или нескольких блоков потоков, каждый из которых состоит из одного или более потоков.

  • GPU назначает один или несколько блоков на выполнение в SM в зависимости от доступных ресурсов. Все потоки одного блока назначаются и выполняются на одном и том же SM. Это делается для эффективного использования локальности данных и синхронизации между потоками.

  • Потоки, назначенные на SM, дополнительно группируются по 32, эта группа называется warp. Все потоки внутри warp-а выполняют одну и ту же инструкцию одновременно, но на разных частях данных (SIMT). (Хотя в новых поколениях GPU также поддерживается независимое планирование потоков.)

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

Заключение

В настоящее время GPU используются повсеместно, но их архитектура и модель выполнения принципиально отличаются от CPU. В этой статье мы рассмотрели различные аспекты GPU, включая их архитектуру и модель выполнения. 


Про востребованные языки программирования и практические инструменты эксперты OTUS рассказывают в рамках онлайн-курсов. По этой ссылке вы можете ознакомиться с полным каталогом курсов, а в календаре — записаться на открытые уроки.

Комментарии (0)