7994420702;horizontal

Параллельные вычислительные процессоры NVIDIA: настоящее и будущее, часть 2

Память  

Память, в текущих CUDA-устройствах, находится на отдельных микросхемах на видеоплате, как системная память на системной. Поэтому, время доступа к ней исчисляется несколькими сотнями тактов. Но, имея сотни исполняющихся нитей, можно частично скрыть эту латентность, все равно в один момент может исполняться только группа из 32 нитей. Остальные пока подождут данных. Таким образом, можно сэкономить на размерах различных кэшей и, вместо памяти, на кристалле разместить ещё больше исполнительных устройств. Вот в этом проявляется использование существенной многопоточности алгоритма.

Также, есть одна специальная возможность, доставшаяся от далеких предков: это доступная только для чтения текстурная память, которая читается при помощи текстурных блоков. Она имеет небольшой кэш 6–8 Кб на мультипроцессор и общий L2-кэш 256 Кб на всё устройство. С её помощью, можно ускорить доступ к немодифицируемым, в процессе работы программы, данным. Можно сказать, исходным данным. Текстурные кэши оптимизированы для двумерных массивов, что предоставляет неплохую оптимизационную возможность для приложений, которые работают с данными такого типа, изображениями например.

Локальная память  

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

Но часто бывает, что нужно хранить большой объем промежуточных вычислений. Для этого существует локальная память мультипроцессора, которая в CUDA-терминологии называется разделяемой (shared). Потому, что она общая для всех нитей одного блока. Да, нити объединяются в блоки, это задается программистом при запуске программы. В отличие от размера варпа, равного 32 нитям и задаваемого требованиями железа, размер блока может варьироваться. Для простоты изложения концепции можно было бы считать, что в один момент мультипроцессор может исполнять один блок, состоящий из 1024 нитей, который обладает всей локальной памятью мультипроцессора, которая вся целиком доступна каждой нити блока. Но в реальности, размер блока может быть любым, в пределах 512 нитей и кратным размеру варпа, и мультипроцессор может одновременно выполнять несколько блоков, и память мультипроцессора, в этом случае, делится поровну между этими блоками.

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

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

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

Также есть общая для всех нитей, доступная только для чтения, память программных констант, размером 64 Кб, с быстрым доступом. У каждого мультипроцессора есть 8 Кб специального кэша для констант.

Регистры и инструкции  

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

И опять, благодаря большому количеству нитей на мультипроцессоре, можно полностью скрыть латентность доступа к файлу регистров и иметь много регистров. Сама же ISA вполне обычна, это очень стандартный, простой и компактный, так называемый скалярный RISC с некоторыми важными ограничениями: GT200 не поддерживает С++, то есть, нет виртуальных функций и нет рекурсивных функций. Потому, что нет стека, все на регистрах. Для вычислительных задач это непринципиальный момент, все равно, в главных вычислительных циклах нельзя пользоваться виртуальными функциями, а рекурсивный алгоритм можно изменить так, чтобы не было вызова функций.

Широко используются предикатные регистры, то есть значение регистра устанавливается в зависимости от значения логического выражения, вроде (a>b), которое либо истина, либо ложь. И далее, в коде следующей инструкции, например, перехода, задается, при каком значении какого предикатного регистра она выполняется. Так, переход становится условным. Очень удобно, на самом деле, и для компиляторов, и для ручного кодирования.

Программная модель  

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

Самый простой пример: нужно вычислить некую математическую функцию f от массива значений из 128000 элементов. Мы загружаем этот массив элементов в память устройства, запускаем программу, её код инструкций система загружает на все мультипроцессоры. При запуске мы указываем, например, размер блока 128 нитей, а количество блоков 1000. Каждый мультипроцессор обрабатывает 8 блоков из 128 нитей, каждая нить читает, в соответствии со своим номером, элемент массива чисел, вычисляет функцию и записывает обратно. По мере выполнения всех нитей блока, мультипроцессор загружает на выполнение новые блоки. И далее, мы читаем данные из устройства обратно.

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

Но нити не обязаны исполняться совсем независимо друг от друга. Для нитей внутри одного блока (это примерно 256 нитей), есть стандартные инструкции синхронизации исполнения Wait — ждать, когда все нити синхронизируются. Напомню, что нити абсолютно произвольны в своем поведении и совсем не похожи на SSE-инструкции. Есть так же инструкции атомарной записи данных в память блока, для обмена данными между нитями.

То есть, нити в одном блоке могут переплетаться между собой. А вот блоки, в рамках CUDA-идеологии, предполагаются более независимыми. Они как будто бы исполняются на разных, далеко друг от друга расположенных, кластерах одного большого суперкомпьютера. Каждый из этих кластеров получил свое задание и, в каком порядке они исполняются, программисту неизвестно. Но некоторую синхронизацию, в частных случаях, можно организовать с помощью флагов в глобальной памяти. Например, организовать счетчик выполнения и старта блоков и, в зависимости от номера, блок будет выполнять свою часть задачи. Например, последний исполняющийся блок узнает, что он последний и может выполнить специальный завершающий этап работы. Или можно запустить новую программу, новый этап вычислений, который воспользуется сохраненными в памяти GPU результатами работы предыдущей программы.

Компилирование  

На практике, принципиальная схема создания CUDA-программы такова: поставляемый NVIDIA компилятор встраивается в среду разработки, он компилирует исходный файл с кодом функции, которая должна исполняться на устройстве и превращает его в ассемблерный код для CUDA-устройства, который присоединяется к программе, как ресурс данных. При запуске программы на конкретной системе, этот код, с помощью библиотечной функции, передается видеодрайверу, который компилирует его для имеющегося CUDA-устройства в машинный код, специфичный для данного устройства. Так обеспечивается совместимость между редакциями архитектур. И далее, с помощью вызова библиотечных функций из кода основной программы, данные загружаются в GPU и для исполнения на устройстве вызывается скомпилированная драйвером функция.

Можно также, на этапе компилирования, дополнительно создать несколько вариантов машинного кода для различных типов CUDA-GPU. Тогда драйвер, при запуске программы, может не компилировать ассемблерный код в машинный, а взять уже готовый вариант, если он существует для данного GPU. А если его нет (например, этот GPU ещё не вышел в момент написания программы), то будет компилироваться универсальный ассемблерный код.

Сравнительная производительность  

Итак, мы ознакомились с главными моментами архитектуры CUDA, но смысл её существования в дополнительной производительности. Что можно, на данный момент, ожидать от присутствующих на рынке CUDA-устройств? Для сравнения с тридцатью мультипроцессорами архитектуры GT200, возьмем четырехъядерные Lynnfield вдвое большей частоты и сделаем, для начала, самую грубую оценку теоретического максимума в вычислениях, с вещественными числами одинарной точности. Как раз, у наиболее массовых моделей Lynnfield, частота примерно вдове выше массовых моделей GT200.

GT200, за такт, теоретически выполняет 8*30=240 инструкций, а одно ядро Lynnfield, за два такта (что соответствует одному такту GT200), начинает выполнение четырех SSE-инструкций с 4 парами float-переменных. Таким образом, получается 4 ядра*2 такта*2 инструкции*4 переменных=64 инструкции.

В теории, GT200 может быть примерно в 5 раз быстрее. Кстати, GT200 может за тот же свой такт выполнять инструкцию mul–add, умножения двух операндов и сложения с третьим. Если её посчитать, то в теории и в 10. Но на самом деле, для большинства задач, разница в этом моменте в 2 раза, что является мелочью, ничего бы не изменилось в этом мире, если бы GT200 не умел выполнять mul–add.

Но на практике, все может обернуться самым разным образом. И тот, и другой теоретический максимум, не так легко, на самом деле, достичь. В архитектуре нынешних GPU есть несколько темных углов, которые могут убить реальную производительность, даже теоретически полностью параллельного приложения. Мы их обсудим ниже. Но и Lynnfield`у не просто приблизиться к максимуму, для многих задач четырехкомпонентный SSE трудно применить, а «мягкая» модель параллельного исполнения одной инструкции 32 нитями CUDA прекрасно работает. Потому, что каждая нить CUDA-программы имеет прямой естественный доступ к своим данным, а работать с каждым элементом SSE-вектора в отдельности, непросто. И мелкие дивергенции нитей не оказывают заметного влияния на скорость. И не все программы вообще используют SSE, могут производить смешанные вычисления, но прекрасно распараллеливаемые в рамках CUDA-модели.

Потому, что модель SIMT (single instruction multiple thread — «одна инструкция, много нитей») гораздо гибче, чем SIMD (single instruction multiple data — «одна инструкция, много данных»), ибо в SIMT, эта инструкция может быть любой и в первую очередь — условным переходом. А в SIMD, если что-то случилось с одним элементом данных, который требует специальной обработки, вся параллельность пропадает и очень непросто эффективно программировать такие случаи.

Надо отметить, что и той, и другой системе, для приближения к максимуму, нужна высокая степень параллельности. И если программа эффективно использует SSE и многопоточность, то велики шансы, что она хорошо совместится с архитектурой CUDA. Потому, что два, из трех главных требований к эффективной CUDA-программе, уже частично выполнено: параллелизм на уровне потоков и на уровне инструкций. Остается только решить проблему с памятью. А без SSE, теоретический максимум Lynnfield`а уменьшится в 4 раза, но и вероятные, в такой не поддающейся SSE-оптимизации программе, дивергентные ветвления могут существенно уменьшить скорость CUDA-варианта. Может в 7 раз, а может в 3.

Уже примерный анализ теоретического максимума показывает некоторую бессмысленность его подсчета. Алгоритм может хорошо подойти высокопараллельной CUDA-архитектуре и тогда GPU выиграет с явным преимуществом в данной задаче. А в других случаях, можно получить не столь существенный прирост, не стоящий возни с технологией. Максимум скорее дает оценку, что можно ожидать от CUDA в лучшем случае. Но необходимо помнить, что использовать несколько GPU значительно дешевле, чем системы на CPU. Как известно, многопроцессорные системы — уже другой рынок с другими ценами. Даже если GT200 равен по производительности Lynnfield, можно легко собрать систему с четырьмя GT200 на одной плате, а вот систему с четырьмя CPU купить — это гораздо более ответственный шаг.

Так как CUDA-архитектура предназначена, в первую очередь для вычислений, а CPU универсальны, они выполняют множество серверных задач, для которых стоимость решения — не самый важный параметр, особенно по сравнению с совместимостью. Просто перекомпилировать многие приложения дороже, чем купить новую систему. А не то, что портировать. Но их обязательно надо исполнять и, с ростом бизнеса, потребности увеличиваются. Такой спрос на CPU автоматически удорожает их, по сравнению со специализированными устройствами.

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