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

«Темные углы»

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

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

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

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

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

В случае доступа к 32-битным данным, память представляется разделенной на сегменты по 128 байт и если все нити полуварпа читают или пишут из одного сегмента, то осуществляется всего одна транзакция памяти.

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

Вторая проблема связана с разделяемой памятью, расположенной на мультипроцессоре. Она разделена на 16 банков, как таблица на 16 колонок. И нити осуществляют операции доступа к ней по 16 штук за раз. То есть, сначала первая половина из 32 нитей варпа, исполняющего инструкцию доступа к разделяемой памяти блока, а потом вторая.

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

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

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

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

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

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

Собственно, на этом почти все. Остальные углы менее тёмные. Эти аспекты очень хорошо продемонстрированы на примере программы транспонирования матриц из CUDA SDK, когда скорость возрастает в разы при каждой оптимизации. Небольшая модификация кода влечет значительное изменение производительности. Но пример слишком страшный и приведена очень простая задача, для CPU тоже можно такой привести, а в более сложном приложении, скорость сглаживается.

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

Оптимизация

Вопрос об использовании CUDA возникает, когда требуется увеличить производительность. И это сразу поднимает тему оптимизации программы. Ведь это первый способ ускорения работы. Скорость выполнения оптимизированной и не оптимизированной программы на обычном, современном CPU, может отличаться в десятки раз. Использование многоядерности и SIMD может дать прирост в 16 раз, а оптимизация доступа к памяти ещё в несколько раз.

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

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

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

При оптимизации для классического процессора, тоже очень полезно (и так часто делается) разбить данные на блоки, которые помещаются целиком в L1-кэш и там с ними работать. Для CUDA это часто обязательно, но и дает больший прирост. Современные процессоры так же страдают от нелокального доступа к памяти, прячутся за колоссальными кэшами и так далее. Для CPU очень полезно применить SIMD, это даст прирост в несколько раз. Это же верно и для CUDA, причем применить благодаря мягкой модели SIMT гораздо проще, а эффект гораздо выше. Оптимизация для CUDA поднимает размер банка в несколько раз, при сравнимой ставке.

 /