64
Федеральное агентство по образованию ТОМСКИЙ ГОСУДАРСТВЕННЫЙ УНИВЕРСИТЕТ Факультет информатики Кафедра теоретических основ информатики УДК 681.324 ДОПУСТИТЬ К ЗАЩИТЕ В ГАК Зав. кафедрой, профессор, д.т.н., _________________ Ю.Л. Костюк «____» __________ 2009г Хомюк Сергей Сергеевич РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА ГРАФИЧЕСКОМ ПРОЦЕССОРЕ Дипломная работа Научный руководитель, профессор, д.т.н. Ю.Л. Костюк Исполнитель, студент V курса, гр. 1441 С.С. Хомюк Электронная версия дипломной работы помещена в электронную библиотеку. Файл Администратор Томск – 2009

РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

  • Upload
    others

  • View
    19

  • Download
    0

Embed Size (px)

Citation preview

Page 1: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

Федеральное агентство по образованию

ТОМСКИЙ ГОСУДАРСТВЕННЫЙ УНИВЕРСИТЕТ

Факультет информатики

Кафедра теоретических основ информатики

УДК 681.324

ДОПУСТИТЬ К ЗАЩИТЕ В ГАК

Зав. кафедрой, профессор, д.т.н.,

_________________ Ю.Л. Костюк

«____» __________ 2009г

Хомюк Сергей Сергеевич

РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА

ГРАФИЧЕСКОМ ПРОЦЕССОРЕ

Дипломная работа

Научный руководитель,

профессор, д.т.н. Ю.Л. Костюк

Исполнитель,

студент V курса, гр. 1441 С.С. Хомюк

Электронная версия дипломной работы помещена

в электронную библиотеку. Файл

Администратор

Томск – 2009

Page 2: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

2

Реферат

Дипломная работа 64 с., 17 рис., 1 таб., 11 источников, 7 прил.

ГРАФИЧЕСКИЙ УСКОРИТЕЛЬ, ОБЩИЕ ВЫЧИСЛЕНИЯ, ШЕЙДЕР,

ИСПОЛЬЗОВАНИЕ ВИДЕОКАРТЫ ДЛЯ РЕШЕНИЯ НЕГРАФИЧЕСКИХ ЗАДАЧ,

ГРАФИКА, ТЕКСТУРЫ, GPU, GPGPU, VISUAL STUDIO, DIRECT3D, NVIDIA CUDA,

МЕТОД ГАУССА, МАТРИЦЫ.

Объект исследования – алгоритмы и методики программирования графического

ускорителя.

Цель работы – исследование возможности использования графического ускорителя

для решения задач общего назначение, разработка и реализация алгоритмов параллельных

вычислений.

Метод исследования – теоретического исследование и практическая реализация.

Область применения – Задачи, требующие быстрой обработки данных больших

размерностей и допускающие распараллеливание алгоритма.

Результаты работы – показана возможность использования графического

ускорителя для решения задач общего назначения. Реализован ряд алгоритмов,

демонстрирующих преимущества и недостатки использования графического ускорителя

для решения задач общего назначения. Сформулированы основные принципы разработки

алгоритмов для GPU. Написана библиотека позволяющая использовать графический

процессор для выполнения ряда операций над матрицами.

Page 3: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

3

Содержание

Введение 5

1. Сравнение центрального и графического процессора. 6

2. Программирование графического процессора . 10

3. Общие вычисления с использованием шейдеров. 12

3.1. История развития шейдеров. 12

3.2 Программирование шейдерных программ. 13

3.3. Графические API. 15

3.4. Общие принципы работы с графическими API. 16

3.5. Виды шейдеров. 17

3.5.1. Вершинные шейдеры. 17

3.5.2. Геометрические шейдеры. 17

3.5.3. Пиксельные шейдеры. 18

3.6. Использование шейдеров для решения задач общего назначения. 18

3.7. Реализация алгоритма поиска максимального элемента. 21

4. Общие вычисления с использованием CUDA. 24

4.1. Модель памяти CUDA. 25

4.2. Мультипроцессоры. 28

4.3. Программирование CUDA. 29

4.3.1. Программирование CPU на CUDA. 31

4.3.2. Программирование GPU на CUDA. 32

4.4. Решение задачи транспонирования матрицы на CUDA. 33

4.5. Оптимизация алгоритма транспонирования матрицы. 34

4.6. Алгоритм умножение матриц на CUDA. 35

4.7. Модификация алгоритма умножение матриц на CUDA. 36

4.8. Общий шаблон решения задач на CUDA. 38

5. Реализация библиотеки для работы с матрицами на GPU. 39

Page 4: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

4

5.1 Использование библиотеки. 41

Заключение. 43

Список использованных источников. 44

Приложение A. Исходный код шейдера поиска максимального элемента. 45

Приложение Б. Исходный код ядра транспонирования матриц. 47

Приложение B. Исходный код ядра быстрого транспонирования матриц. 48

Приложение Г. Исходный код ядра умножения матриц. 49

Приложение Д. Исходный код оптимизированного ядра умножения матриц. 50

Приложение Е. Исходный код библиотеки для работы с матрицами. 51

Приложение Ж. Пример использования библиотеки для работы с матрицами. 63

Page 5: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

5

Введение.

Параллельные вычисления на сегодняшний день являются одной из наиболее

актуальных и приоритетных тем для исследования. Во всех ведущих IT компаниях

(Microsoft, Intel, AMD, NVIDIA, Google, и т.д.) уже давно имеются подразделения и

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

систем основанных на алгоритмах параллельной обработки данных. Таким пристальным

вниманием, параллельные вычисления обязаны стремительным ростом объемов данных,

нуждающихся в обработке. Спектр исследуемых задач крайне широк. Сюда входит анализ

и обработка изображений, симуляция физических процессов, прогнозирование различных

процессов, анализ спутниковых данных, финансовые расчеты, электромагнитные

расчеты, нейронные сети и многое другое. Часть этих задач может быть решена за счет

использования распределенных вычислений в облаке (Cloud Computing), другая часть

может быть решена за приемлемое время лишь, будучи запущена на суперкомпьютере

обладающим производительностью в сотни терафлопов (TeraFLoating point Operations Per

Second, TFLOPS). Но также существует ряд задач, которые по тем или иным должны

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

почти что не решаемой. Центральный процессор, являющийся основным вычислительным

устройством любого современного компьютера просто не способен решать эти задачи за

приемлемое время.

Но в настоящий момент большая часть персональных компьютеров оснащается

отдельными видеокартами. Именно они и могут стать решением проблемы обработки

трудоемких вычислительных задач. Графические процессоры изначально проектируются

таким образом, чтобы обрабатывать огромные объемы данных. На сегодняшний день они

способны обеспечить производительность в сотни и даже тысячи миллиардов операций

над вещественными числами в секунду (GigaFLoating point Operations Per Second,

GFLOPS).

Целью данной работы является детальное изучение проблемы использования

графических процессоров для решения задач общего назначения, анализ существующих

решений, а так же составление ряда алгоритмов позволяющих упросить процесс

составления программ для GPU (Graphical Processing Unit).

Page 6: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

6

1. Сравнение центрального и графического процессора.

На сегодняшний день развитие центральных процессоров практически

остановилось. Дальнейший рост вычислительной производительности CPU сопряжен с

рядом сложностей технологического характера. Так, к примеру, увеличение частот

универсального процессора уперлось в физическое ограничение на размер чипа и высокое

энергопотребление. Тем не менее, роста производительности можно добиться за счет

размещения нескольких ядер в одном чипе. Большинство современных настольных

систем оснащается двуядерными процессорами, что позволяет пользователю комфортно

работать с подавляющим большинством прикладных пакетов. В серверных

конфигурациях, где уровни нагрузок на систему существенно выше, как правило,

используются более производительные чипы, оснащенные четырьмя или даже восьмью

ядрами.

Центральные процессоры являются универсальными вычислительными

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

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

множественный поток команд и данных (Multiple Instruction stream Multiple Data stream,

MIMD). Каждое ядро работает отдельно от других, последовательно исполняя инструкции

текущего процесса.

Помимо параллельного исполнения нескольких потоков, современные центральные

процессоры так же поддерживают специализированные векторные вычисления. При этом

преимущество в производительности достигается лишь в том случае, когда необходимо

произвести одну и ту же последовательность действий над большим набором однотипных

данных. SSE2 и SSE3 позволяет производить вычисления над четырехкомпонентными и

двухкомпонентными векторами. В первом случае результат имеет одинарную точность,

во втором – двойную.

Графические процессоры изначально нацелены на решение узкого круга задач,

связанного с компьютерной обработкой графических данных. В связи с этим архитектуры

GPU и CPU существенно отличаются друг от друга. Так, к примеру, в видеочипах от

NVIDIA основной блок представляет собою мультипроцессор с восьмьюдесятью ядрами,

и несколькими тысячами регистров. Графические процессоры от NVIDIA так же

оснащены несколькими видами памяти: локальная, разделяемая общая, константная, а так

же глобальная память, доступная всем мультипроцессорам на чипе.

Page 7: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

7

Рисунок 1 - Устройство CPU и GPU.

Как и векторные расширения центрального процессора, графический процессор

поддерживает SIMD (Single Instruction stream Multiple Data stream) метод вычислений, т.е.

ядра GPU выполняют один и тот же набор инструкций для каждого экземпляра данных.

Такой подход характерен для большинства графических алгоритмов и позволяет наиболее

эффективно решать задачи визуализации графических сцен.

Обобщим основные отличия между архитектурами центрального графического

процессора. CPU создан для последовательного исполнения одного потока инструкций с

максимальной производительностью, а GPU спроектирован таким образом чтобы

единовременно исполнять как можно большее число параллельных потоков.

Для увеличения производительности центрального процессора архитекторы

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

процессорах Intel Pentium для достижения этой цели используется суперскалярное

выполнение, обеспечивающее одновременное исполнение двух инструкций. Современные

процессоры так же имеют сложный механизм внеочередного (упреждающего) исполнения

команд, который позволяет процессору выполнять более 100 команд, обеспечивая

загруженность суперскалярных исполнительных блоков и общее повышение

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

CPU, по-прежнему является последовательным и увеличением количества ядер кратного

увеличения скорости исполнения алгоритма добиться при текущей архитектуре просто

невозможно.

Графический процессор является вспомогательным вычислительным устройством

занимающийся обработкой графических примитивов, которая состоит в следующем: GPU

принимает на вход набор вершин и инструкций для их обработки, далее происходит

растеризация и генерация изображения, представляющего собою набор пикселей. При

этом на каждом из шагов графического конвейера данные никак не зависят друг от друга

и могут быть обработаны параллельно. Именно из-за изначально параллельной

ALU

ALU

ALU

ALU

Блок

управления

КЭШ

Память

Память

Page 8: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

8

организации работы графического процессора в нем используется большое количество

исполнительных блоков, которые легко загрузить, в отличие от последовательного потока

инструкций для CPU.

Рисунок 2 - Графический конвейер.

У CPU и GPU так же имеются существенные различия в принципах организации

доступа к памяти. В то время как CPU ориентирован на алгоритмы со случайным

доступом к памяти, в GPU доступ осуществляется последовательно и, если в некоторый

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

сказать, что далее будут затребованы идущие следом данные. Аналогично обстоит дело и

с записью информации в GPU. Кроме того, большинство задач решаемых на графическом

процессоре предполагают интенсивную работу с большими объемами данных. В CPU

проблема задержек доступа к памяти решается за счет использования технологии

кэширования информации и предсказаний ветвления кода. GPU обходит эту проблему

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

доступа к памяти, видеочип попросту переключается на другой процесс, уже получивший

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

в видеокартах, обладает куда большей пропускной способностью, нежели оперативная

память.

Как уже было сказано выше, центральный процессор использует механизм

кэширования данных для снижения задержек доступа к памяти. В графических

процессорах так же применяется кэширование. Но в отличие от CPU, где кэш-память

Обработка вершин

Данные

Растеризация

Обработка точек

Изображение

Page 9: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

9

занимает существенную часть чипа, в GPU под кэш отводится всего 128-256 килобайт, и

используется он скорее не для снижения задержек, а для увеличения полосы пропускания.

В графических процессорах так же имеется аппаратная поддержка

многопоточности. Использование множества потоков на CPU не является

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

временными задержками и может занимать до нескольких сотен тактов. К тому же ядро

центрального процессора способно исполнять всего 1-2 потока единовременно. В GPU

разработчики смогли добиться мгновенного переключения между потоками (всего за 1

такт), а каждое из ядер графического процессора поддерживает до 1024 потоков.

В итоге можно сказать, что в отличие от современных центральных процессоров,

являющихся универсальными вычислительными устройствами, одинаково эффективно

справляющимися с большинством задач, графические процессоры имеют куда более

узкую направленность. GPU спроектирован таким образом, чтобы максимально

эффективно решать задачу обработки множественных данных. И если в CPU

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

максимальной унифицированности, то в GPU значительно большее число транзисторов на

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

блоки управления исполнением и кэш-памяти, накладывают значительные ограничения на

структуру алгоритмов исполняемых на GPU. Графический процессор не столь эффективен

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

Рисунок 3 - Динамика роста пиковой производительности GPU и CPU.

Page 10: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

10

2. Программирование графического процессора.

До недавних пор графический адаптер не представлял почти никакого интереса.

Все программирование, по сути, сводилось к простейшему определению набора объектов

находящихся на сцене, указанию их положения и свойств, наподобие текстур, цветов,

освещенности. Далее графический адаптер попросту выполнял тесты глубины и

прозрачности, формируя тем самым "обзор" из заранее заданной позиции. Но для

разработчиков компьютерных игр (имена эта индустрия является основной сферой

применения GPU) этого явно было недостаточно. Они отчаянно нуждались в мощном

сопроцессоре, который взял бы на себя визуализацию сложных эффектов. Так что

появление новых, более мощных, а главное, программируемых графических процессоров,

оставалось лишь вопросом времени.

На сегодняшний день на рынке графических процессоров представлено огромное

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

мобильных решений и заканчивая мощными вычислительными платформами вроде

NVIDIA Tesla.

Рисунок 4 – Видеокарта NVIDIA Tesla.

Разработчики программного обеспечения, впрочем, тоже не остались в стороне. На

данный момент имеется несколько проектов, чьей конечной целью является создание

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

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

CUDA (Compute Unified Device Architecture) от NVIDIA, Shader Model и CTM (Close To

Meal) от AMD. Остальные решения не представляют почти никакого интереса, так как они

по сути своей являются всего лишь простейшими бенчмарками (benchmark) для

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

В данной работе мы детально рассмотрим использование NVIDIA CUDA и Shader

Model для решения задач общего назначения на графическом процессоре. О CTM скажем

лишь то, что это разработка компании AMD проектируемая исключительно для

Page 11: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

11

вычисленных систем, наподобие рабочих станций и серверов. Запуск CTM программ

возможен лишь на процессоре AMD Stream. По сути, данная разработка является

аналогом NVIDIA CUDA. Основное их отличие заключается в том, что CTM работает на

более низком уровне и предполагает, что разработчик хорошо знаком с архитектурными

особенностями процессоров AMD Stream.

Page 12: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

12

3. Общие вычисления с использованием шейдеров.

3.1. История развития шейдеров.

Впервые технология шейдеров была представлена компанией Pixar в проекте

RenderMan. Эти шейдеры не имели полной аппаратной реализации и исполнялись

совместно на CPU и GPU. Весь процесс обработки изображения делился на шесть частей,

каждая из которых использовала свой отдельный тип шейдера. Это были шейдеры

источников света (light shader), шейдеры поверхностей (surface shader), деформирующие

шейдеры (displacement shader), шейдеры объѐма (volume shader), шейдеры трансформаций

(tramsformation shader) и шейдеры отображения (imager shader). Для разработки

шейдерных программ компанией Pixar был разработан собственный язык Pixar Shading

Language. Но без аппаратной поддержки он не представлял особо интереса, так как не был

пригоден для визуализации изображений в реальном времени.

Первые графические процессоры с аппаратной поддержкой официального

стандарта шейдеров ( Shader Model 1.0/1.1 ) появились лишь в 1998 году в семействе GPU

GeForce 256 (NV10), выпущенных корпорацией NVIDIA. Но возможности данного GPU

были крайне малы, и их кое-как хватало на визуализацию простейших эффектов. К тому

же, шейдеры первой версии являлись сугубо линейными и простыми программами. В них

не было ни функций, ни структур, ни даже условных переходов, без которых, по сути,

невозможно построить сколько-нибудь сложный алгоритм. Набор поддерживаемых

инструкций так же был невелик. Помимо этого на шейдерную программу накладывался

ряд крайне жестких ограничений. К примеру, в Shader model 1.1 вы не могли написать код

длиннее, чем 8 базовых инструкций.

Ситуация заметно улучшилась с выпуском GeForce3 (NV20), появившемся на

прилавках в 2001 году. Но настоящий прорыв в сфере производства программируемых

GPU произошел в 2002 году, ознаменовавшемся выпуском плат семейства GeForce FX от

NVIDIA и Radeon 9500 от ATI (была куплена AMD в 2006 году). Это были первые GPU,

поддерживающие шейдеры второй версии (Shader Model 2.0). Данная модель

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

Существенно увеличился лимит на количество команд в программе (64 базовые

инструкции), а также на максимальное число обращений к текстурам. Во второй версии

шейдеров появилась поддержка условных переходов, функций и структур [2]. Именно с

этого момента времени можно говорить о возможности использования графического

процессора для решения задач общего назначения [8].

Разумеется, развитие шейдеров не остановилось с выходом Shader Model 2.0. В

Shader Model 3.0 была добавлена возможность динамических переходов в вершинных и

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

существенно расширило возможности по их применению. Так же была увеличена

максимальный размер шейдера и добавлена поддержка циклов.

Последней актуальной на сегодняшний день версией шейдеров является Shader

Model 4.0. В данной версии число констант и количество инструкций в шейдерной

программе почти не лимитированы, а вложенность текстурных выборок уже не

Page 13: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

13

ограничивается, как это было во второй версии. Основным нововведением Shader Model

4.0 являются геометрические шейдеры (Geometry Shader), которые занимаются

обработкой графических примитивов (точки, линии и треугольники) полученных после

выполнения вершинного шейдера.

Так же, вскоре должна появится пятая версия шейдеров, которая войдет в состав

графического API DirectX 11. Судя по предварительным версиям, в Shaders Model 5.0

будет введен новый тип шейдера - вычислительный шейдер (Compute Shader), который

существенно расширит функциональные возможности графического процессора.

Вычислительный шейдер вызывается как регулярный массив потоков. Потоки делятся на

группы. Каждая из групп имеет по 32 килобайта памяти, разделяемой между потоками

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

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

произвольным доступом к графическим ресурсам: текстурам, массивам вершин, и целям

визуализации (Rendering target). Вызов вычислительного шейдера заменяет все стадии

конвейера визуализации. Тем не менее, можно смешивать вычислительные шейдеры и

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

обработка изображения после визуализации вычислительным шейдером.

3.2 Программирование шейдерных программ.

Выполнение шейдерных программ происходит в процессе визуализации

изображения, который разбит на множество последовательных шагов [1, 3]. Иными

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

Page 14: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

14

Рисунок 5 – Схема графического конвейера.

1) В самом начале графического конвейера расположен блок обработки вершин.

Программа, по которой происходит обработка, называется вершинным шейдером (Vertex

Shader). Помимо значения координат к вершинам так же могут быть привязаны

текстурные координаты, цвет, нормали, и.т.д. Шейдерная программа обрабатывает их,

выдавая трансформированные вершины, заданные в логической системе однородных

координат и лежащих в промежутке от -1.0 до 1.0 . Так центру соответствует точка с

координатами - (0,0,0,1). Так же на данном этапе вычисляется освещенность каждой

точки, находящейся в вершинном буфере.

2) На следующем этапе идет процесс сборки вершин в примитивы. В зависимости

от заданных параметров это могут быть треугольники (как правило, это именно они),

линии, точки, и т.п. На этом же этапе идет отсечение невидимых, скрытых и полностью

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

необходимости визуализировать то, чего не видно. В итоге после завершения данной

стадии конвейера мы уже имеем дело с потоком каких-то примитивов, а не с набором

никак не связанных вершин.

3) После того как из точек были собраны графические примитивы в дело вступает

геометрический шейдер (при условии что программа была написана на Shader Model 4.0

Набор вершин.

Вершинный процессор

Вершинный шейдер

Геометрический процессор

Геометрический шейдер

Растеризатор

Пиксельный процессор

Пиксельный шейдер

Набор текстур.

Набор примитивов

Набор треугольников

Набор точек

Буфер кадра.

Изображение

Page 15: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

15

или выше), который преобразует существующие примитивы и при необходимости

добавляет новые.

4) Далее координаты все вершины трансформируются в оконную систему, в

которой они и будут отображены на экран. Следует так же отметить, что программист

может с самого начала задавать все вершины в оконных координатах. В этом случае

необходимость в 1-3 шагах конвейера отпадает.

5) После окончания процесса трансформации координат в оконную систему

следует этап растеризации, на котором примитивы преобразуются в наборы пикселей

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

которой каждый пиксель получает дополнительные свойства, помимо его координат. Так,

например, задав цвета для вершин линии, мы получим плавный градиентный переход по

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

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

окончанию выполнения данного этапа, мы теряем какую-либо связь с вершинным

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

6) Теперь поток пикселей, полученный в результате интерполирования

примитивов, попадает на вход пиксельному шейдеру (Pixel Shader), где он обрабатывается

в соответствии с заданной программой. Помимо цвета пикселя, на вход к шейдеру могут

подаваться и другие параметры. Чаще всего это значение текстурных координат, по

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

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

цвет итогового пикселя, поступающего на вход следующему этапу графического

конвейера.

7) Постобработка пикселей представляет собой занесение результатов выполнения

шейдерной программы в буфер кадров, откуда он может быть выведен на экран или

сохранен в текстуру.

3.3. Графические API.

На сегодняшний день существует два основных API позволяющих

программировать графический конвейер: Direct3D и OpenGL. Каждое из них имеет свои

преимущества и недостатки.

OpenGL.

OpenGL, является, пожалуй, самым распространенным API для программирования

GPU. Он поддерживается большинством современных платформ, к примеру, существуют

эффективные реализации OpenGL для таких сред как Windows, Linux, MacOS и PlayStation

III. OpenGL был разработан SGI (Silicon Graphics Incorporated), который позже, в 1992

году возглавил консорциум OpenGL ARB(Architecture Review Board) в состав которого

сейчас входят такие производители профессиональных и потребительских графических

аппаратных средств, как SGI, 3Dlabs, Matrox и Evans & Sutherland, ATI и NVIDIA. Из

Page 16: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

16

производителей аппаратного обеспечения входящих в состав ARB можно назвать Intel,

IBM, Apple, Dell, Hewlett-Packard и Sun Microsystems. Так же нельзя не упомянуть одного

из крупнейших разработчиков игрового программного обеспечения – IdSoftware. Из

достоинств OpenGL можно назвать: невероятную гибкость, открытость и расширяемость,

а так же упомянутую выше кроссплатформенность. К недостаткам OGL можно, отнести

лишь одну, но крайне существенную особенность - крайне медленные темпы развития и

обновления версий OpenGL. Это связанно с постоянно возникающими разногласиями и

нестыковками между членами консорциума, а это попросту недопустимо в стремительно

развивающемся мире графических адаптеров.

Direct3D.

Несмотря на то, что все операционные системы семейства Windows, начиная с 95-

ой версии, способны превосходно работать с библиотеками OpenGL, Microsoft

параллельно ведет разработку и поддержку своего собственного API для

программирования графических адаптеров. Direct3D является частью проекта DirectX,

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

DirectSound используется для работы со звуком, DirectInput и DirectPlay для работы с

внешними контроллерами и сетью соответственно. Direct3D, в отличие от OpenGL, не

является кроссплатформенными и поддерживается лишь системами Microsoft Windows и

Microsoft XBox. Но благодаря тому факту, что Microsoft регулярно выпускает новые

версии DirectX, его API всегда поддерживает возможности новейших видеокарт.

3.4. Общие принципы работы с графическими API.

Direct3D и OpenGL имеют ряд серьезных различий в их структуре и наборе

предоставляемых функций, но тем не менее они выполняют одну и ту же роль, а именно

подготавливают систему для исполнения кода шейдера на GPU [7]. Этот процесс в общих

чертах может быть разбит на следующие этапы:

1. Создание графического устройства (device) представляющее собой

своеобразную программную модель графического процессора. Именно через

него осуществляется почти работа с GPU.

2. Подготовка буфера вершин (vertex buffer), содержащего перечень координат

всех вершин, а так же некоторых дополнительных параметров. К примеру, в

этой роли может выступать значение освещенности вершины, ее текстурные

координаты (они используются для привязки текстуры к графическому

примитиву), векторы нормали и.т.д.

3. Подготовка текстур. На этом этапе все текстуры используемые приложением

копируются в специально отведенную для них область графической памяти,

откуда, позже будут считаны шейдерной программой.

4. Загрузка файлов-эффектов, представляющих собой набор шейдеров,

используемых при визуализации текущего объекта.

Page 17: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

17

5. Загрузка в память графического адаптера всех матриц и констант, необходимых

для исполнения эффекта. На данном этапе так же происходит ассоциация

самплеров (sampler) эффекта с ранее загруженными текстурами.

6. Далее следует ключевой этап – визуализация сцены (Scene). Именно здесь в

дело вступает графический конвейер, который проделывает всю работу по

превращению набора точек в законченное трехмерное изображение.

7. После окончания этапа визуализации буфер кадра содержит изображение,

являющееся результатом рендеринга. Далее оно может быть либо отображено

на экран, либо сохранено в текстуру.

Следует заметить, что перечисленные выше этапы едва ли можно назвать четкой

инструкцией к написанию графических приложений. Часть из них можно попросту

выкинуть, часть поменять местами. Все зависит от конкретной реализуемой задачи.

3.5. Виды шейдеров.

3.5.1. Вершинные шейдеры.

Начнем с рассмотрения вершинных шейдров (vertex shader), которые по сути своей

являются логическим продолжением идеи геометрического сопроцессора, выполнявшего

самые простые функции, вроде обработки поворота камеры, в первых графических

адаптерах. Иными словами, он занимается разнообразными геометрическими

преобразованиями. К примеру, вершинный шейдер просто незаменим в том случае, если

вы решили анимировать листву на деревьях. Для этого вы просто пишете небольшую

программку, которая в зависимости от текущего момента времени чуть изменяет взаимное

расположение вершин, составляющих поверхность листа дерева. Другой сферой

применимости данного вида шейдеров является анимация при помощи изменения

текстурных координат вершины. С использованием данной методики можно легко и

просто имитировать мимику человеческого лица, без каких-либо затрат со стороны CPU.

Вершинный шейдер является отправной точкой для исполнения большинства

графических приложений. Исключение составляют лишь те случаи, когда программист

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

Данные, полученные в результате действия вершинного шейдера, одновременно являются

входными для геометрического шейдера.

3.5.2. Геометрические шейдеры.

Геометрический шейдер занимается обработкой графических примитивов (точки,

линии, треугольники). В отличие от вершинного шейдера оперирующего лишь над

отдельными точками он может производить операции над треугольниками целиком.

Page 18: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

18

3.5.3. Пиксельные шейдеры.

Теперь перейдем к рассмотрению пиксельных шейдеров (pixel shader), которые, по

сути, являются важнейшей составляющей любого графического процессора [9]. Это

обусловлено следующими фактами: во-первых, именно результат их работы первым

делом бросается в глаза при визуализации сцены, а во-вторых, их попросту невозможно

заменить, по той причине, что на них ложится просто колоссальная работа, которая никак

не может быть выполнена CPU. Это связанно с тем фактом, что на вход к пиксельному

шейдеру поступают данные, полученные в результате растеризации примитивов [3].

Иными словами, на каждые три точки (в случае работы с треугольными примитивами),

обрабатываемых вершинным шейдром, приходятся сотни, а возможно и тысячи пикселей,

нуждающихся в обработке со стороны пиксельного шейдера. Пиксельные шейдеры — это

рельефные стены и естественное освещение (в том числе — динамическое и от многих

источников света), рябь на воде и блики света на металлических и стеклянных

поверхностях, очень реалистично выглядящие криволинейные поверхности и

разнообразные спецэффекты. С вычислительной точки зрения пиксельный шейдер обычно

задает модель расчета освещения отдельно взятой точки изображения. Помимо этого, с

помощью пиксельных шейдеров можно автоматически генерировать текстуры

(стилизацию под дерево, воду, или, к примеру, блики на дне ручья, отбрасываемые рябью

на его поверхности).

Поскольку нагрузка на блоки закраски гораздо выше, а самих блоков, как правило,

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

шейдеров. Помимо арифметических инструкций присутствуют и специализированные

«текстурные», осуществляющие выборки цвета и арифметические вычисления с данными

текстур.

Ещѐ одной весьма интересной и полезной особенность пиксельного шейдера

является тот факт, что данные, полученные в результате его исполнения, попадают

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

оперативной памяти CPU или же отображены на экране.

3.6. Использование шейдеров для решения задач общего

назначения.

Теперь, когда мы имеем общее представление об устройстве и базовых принципах

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

вопроса написания программ общего назначения для GPU (General-Purpose Computing on

Graphics Processing Units) [6]. Как вы, наверное, уже поняли, процесс программирования

GPU едва ли можно назвать простым. Не говоря уж о написании эффективных

приложений, использующих весь потенциал и возможности графического ускорителя.

Главным образом это связанно с огромным числом ограничений, накладываемых

архитектурой GPU на шейдерную программу. Единственное, что остается разработчику,

так это попытаться подогнать свой алгоритм под требования, предъявляемые ему

графическим ускорителем.

Page 19: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

19

Итак, прежде всего, следует определиться со способом представления входных

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

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

далее просто наблюдать за тем, как центральный процессор один за другим обрабатывает

элементы этой структуры. В GPU же, увы, это представляется абсолютно невозможным.

На выбор разработчикам предоставляется всего три вида входных данных:

Константы. Они не представляют для нас почти, что никакого интереса, поскольку

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

вроде матриц преобразования и коэффициентов преобразования.

Наборы вершин, составляющих поверхность объекта рендеринга. Они выглядят

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

может задавать и изменять их на уровне CPU приложения. Дело в том, что эти значения

хранятся в так называемом буфере вершин (Vertex Buffer), который формируется при

помощи одного из графических API ещѐ на стадии подготовки. К тому же, структура

элементов, составляющих буфер вершин, обладает изрядной долей гибкости и позволяет

вводить дополнительные компоненты (помимо самих координат) вроде векторов

нормалей и уровней освещенности. Но, увы, все эти достоинства сводятся на нет, тем

фактом, что вершинный буфер доступен лишь на первой стадии графического конвейера,

после чего им ещѐ предстоит пройти через этап «сбор примитивов», а также через

растеризатор, в результате работы которого будет уже крайне тяжело выделить отдельные

вершины.

Текстуры. Таким образом, у разработчика GPGPU приложения не остается

никаких вариантов, кроме как использовать третий вид входных данных – текстуры. Они

являются наиболее естественным способом представления данных для GPU и, по сути,

являются двумерными массивами, состоящими из текселей (четырехмерные вектора,

используемые для определения цветовых значений). В графических приложениях

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

«натягиваются» пиксельным шейдром на растеризованные примитивы. Путем изменения

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

этого, текстуры позволяют оперировать с довольно-таки большими объемами

информации. К примеру, даже устаревший видео адаптер ATI Radeon 9600 поддерживает

текстуры размерностью 4096x4096 текселей, что составляет 67 108 864 элементов, каждый

из которых представляет собой 32-х битное число с плавающей точкой (32bit floating

point). К недостаткам, пожалуй, можно отнести лишь некоторые сложности, связанные с

сохранением и извлечением данных из текстуры. Дело в том, что 128-битные изображения

встречаются редко, и поэтому не существует практически никаких средств позволяющих

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

помощи графических потоков (GraphicsStream), позволяющих писать в заблокированные

участки текстуры (LockRectangle).

Далее мы должны определиться со способом представления области рендеринга. В

классической задаче визуализации изображений она определяется набором примитивов,

построенных по точкам, хранящимся в вершинном буфере. В GPGPU у нас отпадает

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

Page 20: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

20

правило, используется два треугольных примитива, которые полностью покрывают

обрабатываемую текстуру. В этом случае мы можем полностью отказаться от

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

вершин треугольников сразу же в экранных координатах. В сложных задачах мы можем

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

выделяя тем самым некоторые области, которые будут обработаны шейдерными

программами на данном этапе визуализации [5].

Теперь рассмотрим правила формирование кода ядра. Как вы, наверное, уже

поняли, в роли главного действующего лица при решении задач общего назначения на

графическом ускорителе выступает пиксельный шейдер. Этот выбор обусловлен тем

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

без труда может быть извлечен. Этого можно добиться, если в качестве цели для

рендеринга указать не экран, а текстуру (данная методика получила название render-to-

texture), из которой данные могут быть извлечены при помощи все той те технологии

графических потоков. А во-вторых, дело в том, что во всех современных видео адаптерах

число пиксельных процессоров намного превосходит число вершинных процессоров. Как

уже упоминалось ранее, это связано с тем фактом, что входные данные для pixel shader

формируются путем интерполирования данных, полученных из vertex shader, а это

предполагает существенное увеличение потока данных. К тому же, несмотря на то, что в

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

из пиксельных программ, в полной мере реализовать все возможности связанные с

использованием текстур могут лишь вторые.

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

Прежде всего, мы сталкиваемся с тем фактом, что позиция в текстуре, куда будет

произведена запись результата выполнения пиксельного шейдера, четко определяется ещѐ

до начала его работы и потом уже никак не может быть изменена. То есть, наш алгоритм

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

место. Разумеется, в некоторых задачах, вроде сортировки массива, этого просто

невозможно добиться за одну итерацию. Выходом из сложившейся ситуации является

применение методики, которую можно поименовать, как многопроходный рендеринг,

основная идея которого заключается в следующем: после окончания очередной итерации,

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

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

конечном счете, приходим к решению нашей задачи. Следующее ограничение связано с

тем, что после того, как щейдер произвел вычисление цветового значения очередного

текселя, результат попадает в буфер кадра, который открыт шейдерам лишь для записи.

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

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

наш алгоритм должен удовлетворять ещѐ одному свойству – каждый элемент данных

обязан обрабатываться абсолютно независимо от других. Решение все то же –

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

на следующей итерации, что делает ее доступной для чтения. Следует заметить, что

«многопроходный рендеринг» едва ли можно назвать панацеей, так как ведет к

Page 21: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

21

существенному снижению общей производительности приложения, но, увы, в

большинстве случаев иного выхода просто не существует.

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

шейдера. Это и предел на количество считываний данных из текстур, и максимальная

длина кода шейдера (в командах), и количество доступных регистров.

3.7. Реализация алгоритма поиска максимального элемента.

Далее, в качестве иллюстрации всех идей, изложенных в предыдущей главе, мы

рассмотрим алгоритм решения задачи поиска максимального элемента в массиве на GPU.

В CPU это все просто бы свелось к циклу, внутри которого мы раз за разом сравниваем

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

если очередной элемент превосходит его, то мы попросту выполняем переприсваивание.

В случае с GPU, этот алгоритм, увы, нам совсем не подходит. Во-первых, результат

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

(от этого самого максимального элемента), то есть параллельная обработка данных

невозможна. Во-вторых, на попросту негде хранить этот локальный минимум. В-третьих,

графический адаптер попросту не умеет работать с массивами. Так, что единственное, что

остается разработчику – это полностью пересмотреть алгоритм.

Итак, начнем со способа представления данных. Мы должны перейти от

одномерного массива скаляров к матрице векторов. Для этого мы попросту берем четыре

элемента и записываем их в очередной тексель.

Теперь перейдем к рассмотрению самого алгоритма. Нам необходимо разбить

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

Этого можно добиться, с использованием многопроходного рендеринга. Каждая итерация,

которого сводится к поиску локального максимума в некоторой группе элементов.

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

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

этом, через конечное число шагов, мы гарантированно получим наш максимум, так как он

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

Page 22: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

22

Рисунок 6 – Схема поиска максимального элемента.

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

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

цвет очередного текселя. Так что будет вполне логично обрабатывать группы по четыре

текселя. К тому же, четырех кратное считывание данных из текстуры является более чем

приемлимым для второй версии шейдеров, на которых пишется наша программа.

Итак, мы берем группу из четырех текселей, в каждом из которых выбираем

максимальный элемент и формирует из полученных результатов очередной тексель,

который пойдет прямиком в буфер кадра. Таким образом, за n/4 итераций (где n –

размерность матрицы) мы приходим к одномерной текстуре, содержащей один

единственный элемент – являющийся максимумом в исходном массиве.

Page 23: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

23

Рисунок 7 – Сравнительный график эффективности алгоритмов поиска максимального

элемента.

Как видно из приведенного выше графика время выполнения алгоритма поиска

максимального элемента при больших (более 262144 элементов) размерностях на GPU

меньше, чем на CPU. Это преимущество достигается за счет распределения нагрузки

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

затрачиваемое на подготовку графического процессора к исполнению шейдерной

программы сводит на нет все преимущества данного подхода. Основная проблема

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

приходится создавать текстуры, которые используются в роли контейнеров для передачи

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

приемлемых результатов, необходимо сформировать алгоритм таким образом, чтобы

минимизировать количество входных и выходных данных, сохранив при этом

интенсивность вычислений внутри шейдерной программы.

Тестирование данного и всех последующих алгоритмов проводилось на следующих

конфигурациях CPU и GPU:

Центральный процессор AMD Athlon 64 X2 Dual Core Processor 4000+

2.1GHz, 3GB RAM.

Видеокарта NVIDIA GeForce 8500GT оснащенная 16 процессорами,

работающими на частоте 450MHz и имеющая 256MB DDR2 RAM.

Стоить принять во внимание тот факт, что видеокарту GeForce 8500GT можно

отнести к классу low-end. Использование современных графических процессоров ещѐ

сильнее увеличит разрыв во времени исполнения алгоритма на CPU и GPU.

Page 24: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

24

4. Общие вычисления с использованием CUDA.

Первые идеи о возможности использования собственных видео адаптеров в задачах

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

ещѐ 2004 году. Именно тогда корпорация NVIDIA выпустила свою первую книгу по

программированию графики – GPU Gems. Большую часть книги составляли статьи

посвященные программированию игровой графики, а так же созданию

высококачественных визуальных эффектов. Особый интерес представляют последние

главы этой книги, носящие название «По ту сторону треугольников» («Beyond Triangles»),

в которых содержались первые размышления о том, что мы сейчас называем GPGPU

(General-Purpose Computing on Graphics Processing Units). Но настоящий интерес

представляет вторая книга, из серии GPU Gems выпущенная спустя год, весной 2005, в

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

программирования GPU. Но с теми инструментами и средами, что имелись на тот момент,

эта задача оставалась крайне сложной и практически недоступной для рядовых

программистов. Для решения данной задачи компанией NVIDIA была собрана группа

разработчиков, занимающаяся созданием специального программного и аппаратного

обеспечения. Это направление получило название Compute Unified Device Architecture или

попросту CUDA. Разработка CUDA была анонсирована вместе с чипом G80 в конце 2006

года, а в начале 2007 года разработчикам была представлена первая бета-версия CUDA

SDK. Первая официальная версия CUDA вышла в июне 2007 года.

На данный момент актуальной является вторая версия NVIDIA CUDA. В ней

появилась поддержка вычислений двойной точности, а так же поддержка операционной

системы Microsoft Windows Vista и Mac OS X. Так же в этой версии CUDA появились

дополнительные средства для профилирования и отладки приложений.

Можно сказать, что в NVIDIA был разработан специальный C-компилятор для

GPU, который работает со специальным вычислительным драйвером, позволяющим

выполнять самые разнообразные вычисления, абстрагируясь от аппаратного уровня.

Особо стоит обратить внимание на тот факт, что CUDA поддерживается лишь

процессорами видеоускорителей GeForce восьмого поколения и старше (GeForce 8,

GeForce 9, GeForce 200), а так же Quadro и Tesla.

Перечислим основные характеристики CUDA [10]:

Унифицированное программно-аппаратное решение для параллельных

вычислений на видеочипах NVIDIA.

Большой набор поддерживаемых графических плат (от мобильных до

мультичиповых).

В качестве языка программирования используется расширенный вариант языка

C.

Поддерживает взаимодействие с графическими API OpenGL и DirectX.

Page 25: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

25

Имеется поддержка 32- и 64-битных операционных систем: Windows XP,

Windows Vista, Linux и MacOS X.

Возможность разработки на низком уровне.

Несмотря на тот факт, что при программировании шейдеров и CUDA-программы

может быть использована одна и та же аппаратная часть, эти подходы существенно

отличаются друг от друга. Слабым местом шейдерного подхода является то, что этот

метод не позволяет в полной мере загрузить GPU. Крайне сложно составить алгоритм

таким образом, чтобы загрузить вершинные шейдеры. Использование графического API

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

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

необходимостью их создания и подготовки.

Перечислим основные преимущества CUDA пред шейдер-программами:

Аппаратная поддержка целочисленных и битовых операций. Поддержка

двойного исполнения команд (dual issue).

Свободный доступ к памяти. Возможность работы с памятью, как с массивом

данных.

Инструкции для графического процессора формулируются на расширенном

варианте языка программирования C, что значительно упрощает процесс

изучения.

CUDA обеспечивает доступ к быстрой разделяемой памяти, которая может

быть использована для межпоточного взаимодействия. Эта память так же

пригодна для организации КЭШа.

Более эффективная передача данных между оперативной памятью и памятью

расположенной на видеоускорителе.

Отсутствие необходимости в использовании графического конвейера, который

является избыточным при решении задач общего назначения на GPU.

Все эти преимущества вытекают главным образом из того факта, что CUDA

изначально проектировалась для использования неграфических вычислений на GPU.

Кроме того CUDA делает возможным использование ряда аппаратных средств

графического процессора, которые недоступны при использовании графических API

(OpenGL, DirectX).

4.1. Модель памяти CUDA.

Пожалуй, одной из наиболее важных для разработчиков особенностей CUDA является

свободный доступ к памяти (поддержка scatter и gather операций) с возможностью

побайтовой адресации.

Page 26: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

26

Рисунок 8 – Модель мультипроцессора NVIDIA.

Каждый из потоков, исполняемый на GPU имеет доступ к следующим типам памяти:

Глобальная память – основной тип памяти, имеющий самый большой объем и

доступный для всех мультипроцессоров на видеочипе. Размер глобальной памяти

напрямую зависит от модели видеокарты и варьируется от 256 мегабайт до 4 гигабайт на

Tesla. Обладает высокой пропускной способностью (более 100 гигабайт/с на последних

решениях от NVIDIA), но работа с этим типом памяти сопряжена со значительными

временными задержками (несколько сотен тактов). Глобальная память не кэшируется,

поддерживает линейную адресацию и обычные указатели.

Локальная память – тип памяти, в которой по умолчанию размещаются все

переменные объявленные внутри CUDA-программы. Так же, как и глобальная память, она

весьма медленная и не поддерживает кэширование.

Разделяемая память – это 16-килобайтный блок памяти, доступный на запись и

чтение из всех потоковых процессоров в мультипроцессоре. По скорости этот тип памяти

сравним с регистрами. Основное назначение разделяемой памяти – обеспечение

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

роли программируемого кэша, с помощью которого достигается снижение задержек при

работе с глобальной памятью.

Константная память – область памяти размеров в 64 килобайта используемая для

хранения константных значений программы. Этот тип памяти кэшируется (по 8 килобайт

на мультипроцессор). И при отсутствии необходимых данных в кэше чтение из

константной памяти осуществляется с задержками в несколько сотен тактов.

Разделяемая память

Регистры

Процессор 1

Регистры

Процессор 2

Регистры

Процессор N

ALU

Кэш констант

Кэш текстур

Память видеокарты

Мультипроцессор

Page 27: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

27

Текстурная память – блок памяти, доступный только на чтение всеми

мультипроцессорами. Выборка из этого типа памяти происходит при помощи текстурных

блоков видеочипа. За счет этого возможно выполнение линейной интерполяции без каких-

либо дополнительных затрат. Данный тип памяти кэшируется.

Таблица 1 - Виды памяти доступные для GPU.

Название Расположение Кэшируемость Чтение/Запись Доступность Тип

памяти

Регистровая

память

Чип нет Чтение и

Запись

Ядро Dedicated

HW

Локальная

память

За пределами

чипа

Нет Чтение и

Запись

Ядро DRAM

Общая

память

Чип Нет Чтение и

Запись

Все ядра в

группе

Dedicated

HW

Глобальная

память

За пределами

чипа

Нет Чтение и

Запись

GPU и CPU DRAM

Константная

память

За пределами

чипа

Да Только чтение GPU и CPU DRAM

Текстурная

Память

За пределами

чипа

Да Только чтение GPU и CPU DRAM

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

глобальная, локальная, текстурная и константная память представлены на чипе в виде

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

константная и текстурная память.

При проектировании алгоритма для CUDA крайне важно помнить обо всех видах

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

к глобальной и локальной памяти, т.к. они медленные и не поддерживают кэширования.

Page 28: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

28

Рисунок 9 – Программная модель памяти мультипроцессора.

4.2. Мультипроцессоры.

Видеочипы от NVIDIA состоят из нескольких кластеров текстурных блоков

(Texture Processing Cluster, TPC). Каждый кластер в свою очередь состоит из блока

текстурных выборок и нескольких мультипроцессоров. Мультипроцессор состоит из 8

вычислительных устройств и двух суперфункциональных блоков. Все инструкции в GPU

выполняются по принципу SIMD, т.е. одна инструкция применяется ко всем потокам в

wrap (группа из 32-х потоков). Этот способ выполнения так назвали SIMT (Single

Instruction Multiple Threads) - одна инструкция и много потоков.

На каждый из мультипроцессоров выделяется от 8192 до 16384 регистров (в

зависимости от модели видеокарты), которые являются общими для всех потоков

исполняемых на нем. Так же на каждом процессоре имеется 16 килобайт быстрой

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

блока. Мультипроцессоры так же имеют доступ к видеопамяти, но необходимо быть

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

задержками. Для ускорения доступа и снижения количества обращений к видеопамяти все

мультипроцессоры оснащаются небольшим (8 килобайт) кэшем для констант и текстур.

Мультипроцессоры GPU могут выполнять до восьми блоков и до 24-х wrap,

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

приходится до 768 потоков.

Разделяемая память

Регистры

Поток (0,0)

Локальная

память

Регистры

Поток (1,0)

Локальная

память

Блок (0,0)

Разделяемая память

Регистры

Поток (0,0)

Локальная

память

Регистры

Поток (1,0)

Локальная

память

Блок (1,0)

Сетка

Глобальная память

Константная память

Текстурная память

CPU

Page 29: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

29

Знание архитектуры GPU крайне важно при составлении алгоритма, так как оно

позволяет оптимизировать его под доступные ресурсы видеокарты.

4.3. Программирование CUDA.

Как упоминалось ранее, графический конвейер, используемый для визуализации

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

традиционных API программист вне зависимости от сложности алгоритма всегда обязан

конфигурировать все части графического конвейера. Этот факт существенно затрудняет

использование GPU для решения задач общего назначения, так как даже простое

сложение двух матриц требует выполнение ряда команд по подготовке и отрисовке

изображений во внеэкранном буфере. В итоге на несколько строк шейдерной программы

приходятся сотни строк дополнительного кода. При решении задач с небольшой

размерностью эти дополнительные затраты способны свести на нет весь выигрыш от

использования GPU.

Модель программирования, используемая в CUDA отличается от традиционных

API тем, что полностью скрывает графический конвейер от программиста, позволяя ему

тем писать программы в более привычных для него «терминах» на расширенной вариации

языка C.

Кроме того, CUDA предоставляет программисту более удобную модель работы с

памятью. Больше нет необходимости хранить данные в 128-битных текстурах, так как

CUDA позволяет читать данные напрямую из памяти видеокарты.

В состав NVIDIA CUDA входят два API: высокого уровня (CUDA Runtime API) и

низкого (CUDA Driver API). При необходимости задействовать низкоуровневые функции

графического процессора программист всегда может отказаться от Runtime API в пользу

Driver API. Стоит заметить, что использование обоих API в одной программе не является

возможным.

Рисунок 10 – Схема CUDA API.

CPU

Приложение

CUDA Runtime

CUDA Driver

GPU

Page 30: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

30

Первым шагом при переносе существующего алгоритма на CUDA непременно

является его анализ, цель которого состоит в поиске «узкого места», нуждающегося в

распараллеливании. Как правило, в алгоритме для CPU такие участки заключены в цикл

или рекурсию.

Полный перенос алгоритма на GPU не является возможным, так как графический

процессор не имеет доступа ни к памяти компьютера, ни к устройствам ввода/вывода (за

исключением буфера кадра, который может быть отображен в виде картинки на экране

компьютера). При исполнении программы CPU по-прежнему отвечает за подготовку и

постобработку данных, сама же трудоемкая работа ложиться на GPU. Набор инструкций,

исполняемый на графическом процессоре, называется ядром (kernel). Ядро, по сути,

является развитием концепции шейдеров.

За формирование и компиляцию ядер отвечает CPU. Видеочип просто принимает

уже скомпилированное ядро и создает его копии для каждого элемента данных. Каждое из

ядер исполняется в своем собственном потоке.

Потоки в GPU могут исполняться лишь группами по 32 экземпляра (wrap). При

этом общее число потоков необходимое для решения задачи может превосходить

максимально допустимое для текущей видеокарты. Поэтому каждый такт аппаратное

обеспечение выбирает, какой из wrap будет исполнен. Но если бы в CPU подобное

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

В отличие от шейдеров, где все данные представлены в виде четырехкомпонентных

векторов, данные в ядре скалярны. Такое представление более естественно для

большинства неграфических задач.

Модель программирования CUDA предполагает группировку потоков в блоки –

одно-, двух- или трехмерные матрицы. Взаимодействие между ними осуществляется при

помощи разделяемой памяти. Так же существуют точки синхронизации, позволяющие

привести данные во всех потоках в актуальное состояние.

Рисунок 11 – Схема группировки потоков в блоки и сетки.

GPU

Grid 1

Block (0, 0) Block (1, 0) Block (2, 0)

Block (0, 1) Block (1, 1) Block (2, 1)

Grid 2

Block (0, 0) Block (1, 0) Block (2, 0)

Block (0, 1) Block (1, 1) Block (2, 1)

CPU

Kernel 1

Kernel 2

Page 31: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

31

Каждое из ядер исполняется над сеткой (grid) блоков. В каждый момент времени на

GPU может исполняться лишь одна сетка. Подобная группировка позволяет достичь

максимальной масштабируемости. Если у GPU недостаточно ресурсов для запуска всех

блоков – они будут выполняться последовательно, друг за другом. Это позволяет

разработчику не задумываться о мощности устройства, на котором будет запущено

приложение.

4.3.1. Программирование CPU на CUDA.

В каждом приложение, написанном на NVIDIA CUDA вне зависимости от его

назначения можно выделить ряд общих шагов:

Подготовка памяти. Поскольку GPU не имеет доступа к оперативной памяти

программисту необходимо заранее позаботиться о том, что все ресурсы, необходимые для

исполнения ядра приложения находятся в памяти видеокарты. Для этих целей

используются три основных функции из CUDA SDK: cudaMalloc, cudaMemcpy и cudaFree.

Эти функции имеют тоже назначение, что и стандартные malloc, memcpy и free, но,

разумеется, все операции проводятся над видеопамятью. Так же стоит отметить, что

функция cudaMemcpy имеет дополнительный параметр, обозначающий направление

копирования информации (из CPU в GPU или наоборот).

Конфигурация сетки (grid) и блоков (blocks). Процесс конфигурации крайне

прост и заключается в задании размеров сетки и блоков. Основной задачей программиста

на данном шаге является нахождение оптимального баланса между размером и

количеством блоков. Увеличением количества потоков в блоке можно снизить количество

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

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

регистров выделяемых на блок фиксировано и если количество потоков окажется сильно

большим, то GPU начнет размещать данные в медленной локальной памяти, что

существенно увеличит время исполнения ядра. NVIDIA рекомендует программистам

использовать блоки по 128 или 256 потоков. В большинстве задач такое количество

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

Запуск ядра. Ядро вызывается как обычная функция в языке C. Единственное

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

определенные размерности сетки и блока.

Получение результатов и освобождение памяти. После исполнения ядра

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

при помощи функции cudaMemcpy с указанием обратного направления копирования (из

GPU в CPU). Точно так же, как и в любой C-программе для предотвращения утечек

памяти необходимо освободить все выделенные ресурсы.

Page 32: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

32

4.3.2. Программирование GPU на CUDA.

Написание кода для GPU на CUDA и Shader Model разительно отличаются. При

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

разработчика имеется привычный всем C с рядом дополнительных расширений, при

помощи которых можно получить доступ к аппаратным возможностям GPU и контексту

текущего исполняемого потока.

Функции, составляющие ядро помещаются в файл с расширением cu, который

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

оболочкой над другими инструментами, и вызывает их: cudacc, g++, cl и др. В итоге

компиляции весь код делиться на 2 части: первая часть предназначена для исполнения на

CPU, а вторая содержит в себе объектный код PTX для GPU.

Для определения типа устройства (GPU или CPU) на котором будет исполняться

функция, в CUDA введен ряд спецификаторов:

__host__ - функции помеченным этим спецификатором исполняются на CPU.

Вызываться они могут так же, лишь с CPU.

__global__ - выполняется на GPU, вызывается с CPU.

__device__ - выполняется на GPU, вызывается с GPU.

В CUDA так же предусмотрен набор спецификаторов, определяющих тип памяти

для размещения переменных:

__device__ - данный спецификатор означает, что переменная находится в

глобальной памяти устройства. Этот тип памяти используется для обмена

данными между CPU и GPU, а так же для взаимодействия ядер из различных

блоков.

__constant__ - задает переменную в константной памяти. Данный тип памяти

кэшируется.

__shared__ - задает переменную в разделяемой памяти блока.

Исполняемая на GPU функция может иметь произвольные аргументы. В

большинстве задач в качестве параметров передаются ссылки на участки памяти, в

которые предварительно была скопирована вся необходимая информация, а так же

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

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

который необходимо обработать используются переменные blockDim и blockIdx, которые

содержат в себе размерность блока и индекс соответствующий текущему экземпляру ядра.

Page 33: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

33

4.6. Решение задачи транспонирования матрицы на CUDA.

Сама по себе задача транспонирования матрицы не является сложной, но на ее

примере можно превосходно продемонстрировать основные особенности

программирования графического процессора с использованием CUDA.

Задача транспонирования матрицы легко решается с использованием CPU за счет

использования двух вложенных циклов:

for i = 0; i < n; i++

for j= 0; j < m; j++

x[i,j] = x[j,i];

Подобный подход является нерациональным при решении задачи на графическом

процессоре, так как все инструкции исполняются в рамках одного ядра. Для

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

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

так как на каждом шаге алгоритма необходимо иметь информацию лишь о текущем

положении. Весь алгоритм решения задачи транспонирования матрицы может быть

записан следующим набором инструкций.

1. Вычислить положение текущего экземпляра ядра.

2. Вычислить индексы исходной матрице, основываясь на положении ядра.

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

4. Считать элемент матрицы по заданным индексам.

5. Вычислить индексы в выходной матрице.

6. Сохранить данные в выходную матрицу.

Исходный код алгоритма транспонирования матриц на CUDA находится в

приложении A.

Данный пример превосходно демонстрирует преимущество использования GPU

при решении задач обладающих высокой степенью параллелизма. На рисунке 12

приведены результаты выполнения алгоритма для CPU и GPU на исходных данных

различной размерности. На небольших объемах данных разница почти незаметна, но

начиная с 65536 элементов (матрица 256х256) начинается сказываться квадратичная

трудоемкость алгоритма для CPU. В итоге, на матрице, состоящей из 4194304 элементов

GPU имеет более чем семикратный выигрыш.

Page 34: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

34

Рисунок 12 – Сравнительный график эффективности алгоритмов транспонирования

матрицы.

4.5. Оптимизация алгоритма транспонирования матрицы.

Приведенный выше алгоритм транспонирования матрицы на графическом

процессоре хоть и демонстрирует высокие показатели по сравнению с CPU, но все, же не

является оптимальным.

Большинство задержек, возникающих в ходе выполнении ядра, связаны с

обращением к памяти. В данной задаче чтение из каждой ячейки памяти происходит лишь

единожды, это означает, что использование кэшируемой текстурной памяти никак не

повлияет на общую производительность алгоритма. Но существует другой способ –

использование разделяемой памяти. Идея данной оптимизации заключается в том, что

копирование информации из одного участка глобальной памяти в другой занимает куда

больше времени, чем копирование из глобальной памяти в разделяемую, а потом назад, в

глобальную. Сформулируем алгоритм транспонирования матрицы с учетом этой

особенности графического процессора:

1. Вычислить положение текущего экземпляра ядра.

2. Вычислить индексы исходной матрице, основываясь на положении ядра.

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

4. Скопировать элемент матрицы по заданным индексам в разделяемую память.

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

необходимая информация была считана.

6. Вычислить индексы в выходной матрице.

7. Сохранить данные из разделяемой памяти в выходную матрицу.

Page 35: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

35

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

устройстве графического процессора для составления наиболее эффективных алгоритмов.

На рисунке 13 отображены результаты оптимизации.

Рисунок 13 – Сравнительный график эффективности алгоритмов транспонирования

матрицы с модификациями.

4.6. Алгоритм умножение матриц на CUDA.

Рассмотрим более сложную задачу – умножение матриц на графическом

процессоре. Простейший алгоритм умножения матриц имеет кубическую трудоемкость и

может быть сформулирован следующим образом:

for i = 0; i < n; I++

for j= 0; j < m; j ++

for k = 0; k < l; k++

x[i,j] += a[i,k] * b[k,j];

Попробуем перенести данный алгоритм на графический процессор. Как и в случае

с алгоритмом транспонирования матриц два внешних цикла представляют собой проход

по всем элементам матрицы, они буду реализованы за счет распределения ядер внутри

блоков GPU. В итоге над каждым элементов матрицы должны быть выполнены

следующие действия:

for k = 0; k < l; k++

x[i,j] += a[i,k] * b[k,j];

Page 36: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

36

Тем самым для вычисления одного элемента результирующей матрицы нужно

выполнить 2*l арифметических операции и 2*l чтений из глобальной памяти. Как было

видно в примере с простым транспонированием матрицы, основным лимитирующим

фактором для GPU является скорость доступа к глобальной памяти. В прошлый раз нам

удалось решить эту проблему за счет использования разделяемой памяти. В этом случае

мы не можем применить этот подход напрямую, поскольку размеры разделяемой памяти

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

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

демонстрировать неплохие результаты по сравнению с CPU.

Рисунок 14 – Сравнительный график эффективности алгоритмов умножения матриц.

4.7. Модификация алгоритма умножение матриц на CUDA.

Быстродействия алгоритма умножения матриц на GPU можно повысить за счет

разбиения исходной задачи на множество подзадач. Использование ядра для вычисления

одного единственного элемента результирующей матрицы не является эффективным, так

как требует исполнения огромного числа математических операций и операций чтения

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

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

ядром не один элемент исходных матриц, а целый блок.

Page 37: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

37

Рисунок 15 – Части матриц A и B используемые для вычисления результирующей

матрицы.

Но для вычисления всех элементов в блоке потребует большего числа обращений к

глобальной памяти, нежели, вычисления одного элемента. Вместо 2*n чтений нам

потребуется обратиться к памяти 2 * размер блока * n раз.

Продолжим декомпозицию задачи и разобьем «полосы» A’ и B’ на блоки.

Рисунок 15 – Разбиение частей матриц на блоки.

В итоге разбиения мы можем выразить блок X’ следующим образом:

(1)

Отсюда видно, что мы можем разбить исходную задачу на множество подзадач, в

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

этом если подобрать размер блока соответствующим образом, все необходимые данные

A X

B

A’

B’

X’

A X

B

A1’ A2’ … An’

B1’

B2’

Bm’

X’

Page 38: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

38

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

общей производительности алгоритма.

Рисунок 16 – Сравнительный график эффективности алгоритмов умножения матриц с

модификациями.

Пример с умножением матриц демонстрирует один из основных приемов

распараллеливания алгоритмов. Итоговое решение удается найти за счет пошаговой

декомпозиции исходного алгоритма.

4.8. Общий шаблон решения задач на CUDA.

Не смотря на существенную разницу в назначении алгоритмов, исполняемых на

GPU, можно выделить набор общих инструкций, которых следует придерживаться при

составлении программы для GPU:

1. Выделение набора инструкций исполняемых одним экземпляром ядра.

2. Выделение набора данных, общего для блока ядер и загрузка его в разделяемую

память.

3. Если объемы данных на шаге 2 слишком велики или набор инструкций

выделенных на шаге 1 предполагает слишком интенсивные вычисления задача

декомпозируется на более простые задачи.

4. Производятся вычисления над подгруженными ранее данными.

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

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

содержат большое количество инструкций – стоит уменьшить размерность блоков. Это

позволит более эффективно использовать регистровую память GPU.

Page 39: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

39

5. Реализация библиотеки для работы с матрицами на GPU.

Как показали результаты исследований, описанных в предыдущих главах, CUDA

является более эффективным инструментом для программирования приложений для GPU,

чем Shaders Model. Программы, написанные на CUDA, обладают большей

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

язык для решения задач общего назначения и с самого начала исключила необходимость

использования графического конвейера.

Именно по этой причине CUDA был избран для реализации библиотеки,

позволяющей выполнять, ряд основных операций над матрицами средствами

графического процессора. В эту библиотеку вошли следующие операции:

Умножение матриц.

Транспонирование матрицы.

Обращение матрицы.

Вычисление определителя матрицы.

Решение системы линейных уравнений.

На примере первых двух алгоритмов были рассмотрены основные принципы

проектирования приложений для NVIDIA CUDA. Остановимся подробнее на оставшихся.

Общей чертой этих алгоритмов является то, что все они могут быть решены с

использованием метода Гаусса.

Метод Гаусса является классическим методом решения системы линейных

алгебраических уравнений (СЛАУ). Основная идея данного алгоритма заключается в

постепенном понижении порядка системы и исключении неизвестных.

Алгоритм решения СЛАУ методом Гаусса подразделяется на два этапа:

1. На первом этапе осуществляется так называемый прямой ход, когда

путѐм элементарных преобразований над строками систему приводят к

ступенчатой или треугольной форме, либо устанавливают, что система

несовместна. А именно, среди элементов первого столбца матрицы выбирают

ненулевой, перемещают его на крайнее верхнее положение перестановкой строк и

вычитают получавшуюся после перестановки первую строку из остальных строк,

помножив еѐ на величину, равную отношению первого элемента каждой из этих

строк к первому элементу первой строки, обнуляя тем самым столбец под ним.

Если на какой-то из итераций среди элементов первого столбца не нашѐлся

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

операцию.

2. На втором этапе осуществляется так называемый обратный ход, суть которого

заключается в том, чтобы выразить все получившиеся базисные переменные через

небазисные и построить фундаментальную систему решений либо, если все

переменные являются базисными, то выразить в численном виде единственное

решение системы линейных уравнений. Эта процедура начинается с последнего

Page 40: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

40

уравнения, из которого выражают соответствующую базисную переменную (а она

там всего одна) и подставляют в предыдущие уравнения, и так далее, поднимаясь

по «ступенькам» наверх. Каждой строчке соответствует ровно одна базисная

переменная, поэтому на каждом шаге, кроме последнего (самого верхнего),

ситуация в точности повторяет случай последней строки.

Данный алгоритм изначально не подходит для исполнения на графическом

процессоре, так как является многоитерационным и предполагает наличие множества

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

за счет разбиения алгоритма на ряд независимых задач:

1. Определение необходимости в перестановке строк. Данный шаг является

линейным и исполняется на графическом процессоре лишь по той причине, что

передача информации между GPU и CPU является крайне дорогостоящей

операцией. На этом шаге выполняется проверка, не равняется ли диагональный

элемент матрицы нулю и в случае необходимости ищется строка для перестановки.

2. Перестановка строк в матрице. Осуществляет перестановку двух строк,

определенных на предыдущем шаге алгоритма.

3. Нормализация строки в матрице. Приводит к единице коэффициент, стоящий при

диагональном элементе путем деления всех элементов строки на одно и то же

число.

4. Приведение матрицы. Приводит матрицу к единичному виду. Данный шаг

объединяет прямой и обратные проходы классического метода Гаусса.

Все перечисленные выше шаги выполняются последовательно для каждой строки

матрицы.

Данный алгоритм не позволяет максимально полно загрузить графический процессор,

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

счет использования GPU достигается лишь за счет последнего, четвертого шага, на

котором происходит параллельная обработка всех элементов матрицы.

Приложение Е содержит полную реализацию метода Гаусса для решения задачи

поиска обратной матрицы, вычисления детерминанта и решения систем линейных

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

Page 41: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

41

Рисунок 17 – Сравнительный график эффективности реализаций

метода Гаусса для CPU и GPU.

5.1. Использование библиотеки.

Библиотека для работы с матрицами на GPU инкапсулирует в себе всю работу с

графическим процессором. Для ее использования от разработчика не требуется абсолютно

никаких знаний об устройстве и принципах работы графических ускорителей.

Файл-заголовок «GPUMatrix.h» содержит в себе описание всех функций

библиотеки:

// Вычисление обратной матрицы с использованием GPU

int GPUInverse(float* inMatrix, float* outMatrix, int size);

// Решение системы линейных уравнений с использованием GPU

int GPUSloveSystemOfLinearEquations(float* inMatrix, float* outResult, int size);

// Вычисление детерминанта квадратной матрицы с использованием GPU

int GPUDeterminant(float* inMatrix, int size, float* determinant);

Вне зависимости от своего назначения, все функции в библиотеке принимают на

вход ссылку на массив, содержащий исходную матрицу и размерность этой матрицы.

Число, возвращаемое в результате выполнения функции, является кодом ошибки. В

случае успешного исполнения оно равняется нулю.

Page 42: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

42

Функция «GPUInverse» занимается вычислением обратной матрицы. Помимо

общих параметров данная функция принимает ссылку на массив, который в случае

успешного исполнения, будет заполнен элементами обращенной матрицы.

Назначение функции «GPUSloveSystemOfLinearEquations» - решение систем

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

себя как коэффициенты, стоящие при соответствующих неизвестных, так и правую часть

уравнений. В результате исполнения функции параметр «outResult» будет ссылаться на

массив, содержащий решения СЛАУ.

Функция «GPUDeterminant» позволяет вычислять детерминант матрицы. Так же,

как и в двух предыдущих случаях параметр функции «determinant» будет ссылаться на

результат вычисления.

Пример использования функции GPUInverse приведен в приложении Ж.

Page 43: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

43

Заключение.

В ходе проведенной работы были изучены существующие технологии

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

архитектуры центрального и графического процессоров. На основе проведенных

исследований были сделаны выводы о целесообразности использования графического

процессора для решения задач общего назначения.

Реализация алгоритма поиска максимального элемента в массиве с использованием

технологий программирования графического процессора Shader Model показала, что

несмотря на более чем двукратный выигрыш во времени исполнения алгоритма на GPU

по сравнению с CPU, данный подход не является целесообразным из-за больших

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

вычислений.

Технология программирования графических процессоров NVIDIA CUDA

изначально проектировалась с целью предоставить разработчикам инструмент для

программирования алгоритмов решающих задачи общего назначения на GPU. Реализация

алгоритмов транспонирования и перемножения матриц показало, что использование

данной технологии способно значительно сократить время их выполнения на входных

данных больших размерностей.

Именно технология CUDA была избрана для реализации библиотеки работы с

матрицами на GPU. В основе данной библиотеки лежит алгоритм решения систем

линейных уравнений Гаусса, модифицированный с целью достижения максимальной

производительности при исполнении на графическом процессоре. Результаты

тестирования библиотеки показали, что даже на слабой видеокарте наблюдается более чем

двукратный выигрыш во времени исполнения алгоритма по сравнению с аналогичными

решениями для CPU.

Разумеется, GPU не сможет полностью заменить универсальные процессоры, но

зато они могут существенно разгрузить CPU, взяв на себя выполнение наиболее

трудоемких и сложных задач.

По результатам работы опубликованы тезисы конференции [11].

Page 44: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

44

Список использованных источников.

1 Горнаков С.Г. DirectX 9: Уроки программирования на С++. - СПб.: БХВ-Петербург,

2005. - 400 с.

2 Миллер Т. DirectX 9 с управляемым кодом. - М.: КомБук, 2005. - 386 с.

3 Luna, Frank D. Introduction to 3D game programming with DirectX 9.0. - Wordware

Publishing, Inc, 2003. – 421 c.

4 Gray K. Microsoft DirectX 9 Programmable Graphics Pipeline. - MS Press,

2003 – 458 с.

5 Accelerator: Using Data Parallelism to Program GPUs for General-Purpose Uses / Tarditi

D., Puri S., Oglesby J – Microsoft Research, 2006 – 11 с.

6 GPGPU: General Purpose Computation On Graphics Hardware / Luebke D., Harris M.,

Kruger J., и др. – SIGGRAPH, 2005. – 277 c.

7 Программирование графики с использованием Direct3D // GameDev [Электронный

ресурс] / GameDev.ru, 2001-2006. - режим доступа: http://www.gamedev.ru ,

свободный.

8 GPGPU::Basic Math Tutorial // Dominik Goddeke -- GPGPU [Электронный ресурс] /

Dominik Goddeke 2005-2006. - режим доступа: http://www.mathematik.uni-

dortmund.de/~goeddeke/gpgpu/tutorial.html , свободный.

9 Введение в HLSL // GotDotNet [Электронный ресурс] / Корпорация Microsoft, 2003-

2006. - режим доступа:

http://www.gotdotnet.ru/LearnDotNet/NETFramework/300824.aspx , свободный.

10 NVIDIA CUDA Programming Guide / NVIDIA Corporation, 2008 – 111 с.

11 Хомюк С. С. Использование графического процессора для решения задач общего

назначения. // Информационные технологии и математическое моделирование

(ИТММ - 2008) : Материалы VII Всероссийской научно-практической

конференции с международным участием ( 14-15 ноября 2008). - Томск. Изд-во

Том. ун-та, 2008. - Ч1. - С. 132-134

Page 45: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

45

Приложение A. Исходный код шейдера поиска максимального

элемента.

texture2D streamA; sampler2D texSampler = sampler_state { Texture = <streamA>; }; float matrixWidth; struct PixelInput { float2 texcoord : TEXCOORD0; }; // Пиксельный шейдер. float4 MainPS(PixelInput input):COLOR { float currentX = input.texcoord.x * 4; float step = 1 / matrixWidth; float4 result = float4(0,0,0,0); if(input.texcoord.x < 0.25) { input.texcoord.x = currentX; float4 texel = tex2D(texSampler, input.texcoord); result[0] = max(max(max(texel[0],texel[1]),texel[2]),texel[3]); input.texcoord.x = input.texcoord.x + step; texel = tex2D(texSampler, input.texcoord); result[1] = max(max(max(texel[0],texel[1]),texel[2]),texel[3]); input.texcoord.x = input.texcoord.x + step; texel = tex2D(texSampler, input.texcoord); result[2] = max(max(max(texel[0],texel[1]),texel[2]),texel[3]); input.texcoord.x = input.texcoord.x + step; texel = tex2D(texSampler, input.texcoord); result[3] = max(max(max(texel[0],texel[1]),texel[2]),texel[3]); } return result; } // Техника Main technique Main { // Первый проход pass p0

Page 46: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

46

{ // Задаём пиксельный шейдер. для компиляции шейдера используется профиль ps_2_0 PixelShader = compile ps_2_0 MainPS(); } }

Page 47: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

47

Приложение Б. Исходный код ядра транспонирования матриц.

#ifndef _TRANSPOSEMATRIXSLOW_H_ #define _TRANSPOSEMATRIXSLOW_H_ #include <stdio.h> __global__ void TransposeMatrixSlow(float* inputMatrix, float* outputMatrix, int height, int width) { // Определение текущего индекса int xIndex = blockDim.x * blockIdx.x + threadIdx.x; int yIndex = blockDim.y * blockIdx.y + threadIdx.y; // Проверка что не вышли за пределы матрицы if( (xIndex < width) && (yIndex < height) ) { // Высчитывание индексов int linearInputIndex = xIndex + yIndex * height; int linearOutputIndex = yIndex + xIndex * width; // Транспонирование outputMatrix[linearOutputIndex] = inputMatrix[linearInputIndex]; } }

#endif // #ifndef _TRANSPOSEMATRIXSLOW_H_

Page 48: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

48

Приложение B. Исходный код ядра быстрого транспонирования

матриц.

#ifndef _TRANSPOSEMATRIXFAST_H_ #define _TRANSPOSEMATRIXFAST_H_ #include <stdio.h> #define BLOCK_DIM 16 __global__ void TransposeMatrixFast(float* inputMatrix, float* outputMatrix, int height, int width) { __shared__ float temp[BLOCK_DIM][BLOCK_DIM];

// Определение текущего индекса int xIndex = blockDim.x * blockIdx.x + threadIdx.x; int yIndex = blockDim.y * blockIdx.y + threadIdx.y;

// Проверка что не вышли за пределы матрицы if( (xIndex < width) && (yIndex < height) ) { int linearIndex = width * yIndex + xIndex; // Сохранение во временную переменную temp[threadIdx.x][threadIdx.y] = inputMatrix[linearIndex]; } // Синхронизация всех потоков. __syncthreads(); xIndex = blockDim.y * blockIdx.y + threadIdx.x; yIndex = blockDim.x * blockIdx.x + threadIdx.y; if ( (xIndex < height) && (yIndex < width) ) { int linearIndex = height * yIndex + xIndex; outputMatrix[linearIndex] = temp[threadIdx.x][threadIdx.y]; } }

#endif // #ifndef _TRANSPOSEMATRIXFAST_H_

Page 49: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

49

Приложение Г. Исходный код ядра умножения матриц.

#ifndef _MATRIXMULSLOW_H_ #define _ MATRIXMULSLOW_H_ #define BLOCK_DIM 16 #include <stdio.h> __global__ void matrixMulSlow(float* outputMatrix, float* inputMatrixA, float* inputMatrixB, int n) { int blockX = blockIdx.x; int blockY = blockIdx.y; int threadX = threadIdx.x; int threadY = threadIdx.y; // Временная переменна для результата float sum = 0.0f; int indexA = n * BLOCK_DIM * blockY + n * threadY; int indexB = BLOCK_DIM * blockX + threadX; // Вычисление очередного элемента for(int k = 0; k < n; k++) sum += inputMatrixA[indexA + k] * inputMatrixB[indexB + k*n]; int indexC = n * BLOCK_DIM * indexB + BLOCK_DIM * blockX; outputMatrix [indexC + n * threadY + threadX] = sum; }

#endif // #ifndef _ MATRIXMULSLOW_H_

Page 50: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

50

Приложение Д. Исходный код оптимизированного ядра умножения

матриц.

#ifndef _MATRIXMULFAST_H_ #define _ MATRIXMULFAST_H_ #include <stdio.h> #define BLOCK_DIM 16 __global__ void matMultFast (float* outputMatrix, float* inputMatrixA, float* inputMatrixB, int n) { int blockX = blockIdx.x; int blockY = blockIdx.y; int threadX = threadIdx.x; int threadY = threadIdx.y; // Индекс перевого блока в матрице А int aBegin = n * BLOCK_DIM * blockY; int aEnd = aBegin + n - 1; int aStep = BLOCK_DIM; //Индекс первого блока в матрице В int bBegin = BLOCK_DIM * blockX; int bStep = BLOCK_DIM * n; float sum = 0.0f; for ( int indexA = aBegin, indexB = bBegin; indexA <= aEnd; indexA += aStep, indexB += bStep ) { // Переменные в разделяемой памяти для блоков из матриц А и В __shared__ float sharedMatrixA [BLOCK_DIM][BLOCK_DIM]; __shared__ float sharedMatrixB [BLOCK_DIM][BLOCK_DIM];

// Копирование в разделяемую память sharedMatrixA [threadY][threadX] = inputMatrixA [indexA + n * threadY + threadX]; sharedMatrixB [threadY][threadX] = inputMatrixB [indexB + n * threadY + threadX];

// Синхронизация блоков __syncthreads(); for ( int k = 0; k < BLOCK_DIM; k++ ) sum += sharedMatrixA [threadY][k] * sharedMatrixB [k][threadX]; __syncthreads(); } int indexC = n * BLOCK_DIM * blockY + BLOCK_DIM * blockX; outputMatrix[indexC + n * threadY + threadX] = sum; }

Page 51: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

51

Приложение Е. Исходный код библиотеки для работы с

матрицами.

// Kernels/normalizeRow_kernel.cu // Нормализация строки. Обращает диагональный элемент в единицу. __global__ void normalizeRow (float* matrix, int width, int row) { // Выделение блока-кэша в разделяемой памяти __shared__ float block[1][BLOCK_DIM]; // Получение текущей позиции unsigned int xIndex = blockIdx.x * blockDim.x + threadIdx.x; unsigned int yIndex = blockIdx.y * blockDim.y + threadIdx.y; // Проверка на размерность if ((xIndex >= width) || (yIndex != 0)) return; // Вычисление индекса соотвествующего началу нормализуемой строки unsigned int baseIndex = row * width; // Вычисление ключевого элемента (диаганального) float keyElement = matrix[baseIndex + row]; // Кэширование block[0][threadIdx.x] = matrix[baseIndex + xIndex] / keyElement;

__syncthreads(); // Сохранение данных matrix[baseIndex + xIndex] = block[0][threadIdx.x]; } // Kernels/pass_kernel.cu // Проход. Обращает все элементы в столбце (кроме диагонального) в 0. __global__ void pass (float* matrix, int size, int width, int row) { // Выделение блока-кэша в разделяемой памяти __shared__ float block[BLOCK_DIM][BLOCK_DIM ]; __shared__ float blockRow[1][BLOCK_DIM ]; // Получение текущей позиции unsigned int xIndex = blockIdx.x * blockDim.x + threadIdx.x; unsigned int yIndex = blockIdx.y * blockDim.y + threadIdx.y; // Проверка на размерность

if ((xIndex < row) || (xIndex >= width) || (yIndex == row) || (yIndex >= size))

return; // Вычисление базовых индексов unsigned int index_base = yIndex * width; unsigned int index_row = row * width; // Вычисление идексов для текущей строки unsigned int index_currentRow = index_base + row; unsigned int index_currentElement = index_base + xIndex;

Page 52: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

52

// Вычисление индексов в базовой строке unsigned int index_baseElement = index_row + xIndex; // Вычисление модификатора для текущей строки float modifier = matrix[index_currentRow]; // Кэширование block[threadIdx.y][threadIdx.x] = matrix[index_currentElement]; blockRow[0][threadIdx.x] = matrix[index_baseElement]; __syncthreads(); // Сохранение результатов matrix[index_currentElement] =

block[threadIdx.y][threadIdx.x] – (blockRow[0][threadIdx.x] * modifier);

} // Проход. Обращает все элементы в столбце(кроме диагонального) в 0. __global__ void pass_determinant (float* matrix, int size, int row) {

// Выделение блока-кэша в разделяемой памяти __shared__ float block[BLOCK_DIM][BLOCK_DIM]; __shared__ float blockRow[1][BLOCK_DIM];

// Получение текущей позиции unsigned int xIndex = blockIdx.x * blockDim.x + threadIdx.x; unsigned int yIndex = blockIdx.y * blockDim.y + threadIdx.y; // Проверка на размерность if ((xIndex < row) || (xIndex >= size) || (yIndex == row) ||

(yIndex >= size)) return; // Вычисление базовых индексов unsigned int index_base = yIndex * size; unsigned int index_row = row * size; // Вычисление идексов для текущей строки unsigned int index_currentRow = index_base + row; unsigned int index_currentElement = index_base + xIndex; // Вычисление индексов в базовой строке unsigned int index_baseRow = index_row + row; unsigned int index_baseElement = index_row + xIndex; // Вычисление модификатора для текущей строки float modifier = matrix[index_currentRow] / matrix[index_baseRow]; // Кэширование block[threadIdx.y][threadIdx.x] = matrix[index_currentElement]; blockRow[0][threadIdx.x] = matrix[index_baseElement]; __syncthreads();

Page 53: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

53

// Сохранение результатов matrix[index_currentElement] =

block[threadIdx.y][threadIdx.x] – (blockRow[0][threadIdx.x] * modifier);

} // Kernels/setIdentity_kernel.cu // Добавление единичной матрицы __global__ void setIdentity (float* matrix, int size) { // Получение текущей позиции unsigned int xIndex = blockIdx.x * blockDim.x + threadIdx.x; unsigned int yIndex = blockIdx.y * blockDim.y + threadIdx.y; if ((xIndex >= size) || (yIndex != 0)) return; // Вычисление позиции в матрице unsigned int index = xIndex * 2 * size + size + xIndex; matrix[index] = 1; } // Kernels/swap_kernel.cu // Поменять местами строки матрицы __global__ void swapRows (float* matrix, int width, int row1, int row2) { // Выделение блоков в разделяемой памяти __shared__ float row1Block[1][BLOCK_DIM]; __shared__ float row2Block[1][BLOCK_DIM]; // Получение текущей позиции unsigned int xIndex = blockIdx.x * blockDim.x + threadIdx.x; unsigned int yIndex = blockIdx.y * blockDim.y + threadIdx.y; // Проверка позиции if ((xIndex >= width) || (yIndex != 0)) return; // Вычисление индексов в матрице unsigned int index_row1 = row1 * width + xIndex; unsigned int index_row2 = row2 * width + xIndex; // Сохранение данных в разделяемой памяти row1Block[0][threadIdx.x] = matrix[index_row1]; row2Block[0][threadIdx.x] = matrix[index_row2]; __syncthreads(); // Обмен значений matrix[index_row1] = row2Block[0][threadIdx.x]; matrix[index_row2] = row1Block[0][threadIdx.x]; }

Page 54: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

54

// Kernels/validateRows_kernel.cu // Проверка строк (что на диагонали нет нулей). __global__ void validateRows (float* matrix, int size, int width, int row, int* newRow) { // Получение текущей позиции unsigned int xIndex = blockIdx.x * blockDim.x + threadIdx.x; unsigned int yIndex = blockIdx.y * blockDim.y + threadIdx.y; // Проверка границ if( (xIndex != 0) || (yIndex != 0)) return; newRow[0] = row; int index = row * width + row; if ((matrix[index] > - EPS) && (matrix[index] < EPS)) { newRow[0] = -1; for (int i = row + 1; i < size; i++) { int alt_index = i * width + row; if ((matrix[alt_index] <= - EPS) || (matrix[alt_index] >= EPS)) { newRow[0] = i; return; } } } } // GPUMatrix.h #ifndef GPUMATRIX_H #define GPUMATRIX_H #ifdef __cplusplus extern "C" { #endif // Вычисление обратной матрицы с использованием GPU int GPUInverse(float* inMatrix, float* outMatrix, int size); // Решение системы линейных уравнений с использованием GPU int GPUSloveSystemOfLinearEquations(float* inMatrix, float* outResult, int size); // Вычисление детерминанта квадратной матрицы с использованием GPU int GPUDeterminant(float* inMatrix, int size, float* determinant); #ifdef __cplusplus } #endif #endif

Page 55: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

55

// GPUMatrix.cu // Системные библиотеки #include <stdlib.h> #include <stdio.h> #include <string.h> #include <math.h> #include <windows.h> // CUDA Утилиты #include <cutil_inline.h> #include <GPUMatrix.h> // Размер блока потоков для GPU #define BLOCK_DIM 16 #define EPS 0.0000001 // Вычислительные ядра GPU #include <Kernels/setIdentity_kernel.cu> #include <Kernels/validateRows_kernel.cu> #include <Kernels/swapRows_kernel.cu> #include <Kernels/normalizeRow_kernel.cu> #include <Kernels/pass_kernel.cu> // Вычисление обратной матрицы с использованием GPU int GPUInverse(float* inMatrix, float* outMatrix, int size) { // Код ошибки int error_code = 0; // Общая ширина матрицы (исходная + единичная). int width = 2 * size; // Объем памяти необходимый для хранения матрицы. unsigned int memory_size = sizeof(float) * width * size; float *matrix = (float *) malloc(memory_size); // Инициализация матрицы for (int i = 0; i < size; i++) { for (int j = 0; j < width; j++) { int index = i * width + j; if (j < size) { int in_index = i * size + j; matrix[index] = (float) inMatrix[in_index]; } else { matrix[index] = (float) 0; } } }

Page 56: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

56

// Поиск устройства с максимальной производительностью на котором будут проведены все вычисления. cudaSetDevice( cutGetMaxGflopsDeviceId() ); // Инициализация таймера unsigned int timer = 0; cutilCheckError( cutCreateTimer( &timer)); cutilCheckError( cutStartTimer( timer)); // Инициализация памяти на устройстве float* d_matrix; int* d_row; cutilSafeCall( cudaMalloc( (void**) &d_matrix, memory_size)); cutilSafeCall( cudaMalloc( (void**) &d_row, sizeof(int))); // Копирование данных на устройство cutilSafeCall( cudaMemcpy( d_matrix, matrix, memory_size, cudaMemcpyHostToDevice) ); // Определение параметров исполнения. // Сетка покрывающая всю матрицу dim3 grid(width / BLOCK_DIM, size / BLOCK_DIM); // Потоки покрывающие весь блок dim3 threads(BLOCK_DIM, BLOCK_DIM, 1); // Сетка покрывающая один столбец матрицы dim3 column_grid(size / BLOCK_DIM); // Сетка из одного элемента dim3 single_grid(1); // Сетка покрывающая одну строку матрицы dim3 row_grid(width / BLOCK_DIM); // Единичный поток dim3 single_thread(1); // Приведение правой части матрицы к единичному виду setIdentity<<< column_grid, threads >>>(d_matrix, size); cudaThreadSynchronize(); // Временная переменная для обмена данными между CPU и GPU int* h_row = (int*) malloc(sizeof(int)); // Основной цикл. Приведение всех строк матрицы. for (int row = 0; row < size; row ++) { // Проверка, не стоит ли 0 элемент на диагонали. validateRows<<< single_grid, single_thread >>> (d_matrix, size, width, row, d_row); cudaThreadSynchronize(); // Копирование результата с GPU на CPU cutilSafeCall( cudaMemcpy( h_row, d_row, sizeof(int), cudaMemcpyDeviceToHost) ); // Матрицу невозможно привести к единичному виду - окончание цикла. if(h_row[0] == -1) { error_code = -1;

Page 57: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

57

break; // На диагонали нулевой элемент - нужно поменять местами. } else if (h_row[0] != row) { // Поменять местами две строки матрицы swapRows<<< row_grid, threads>>> (d_matrix, width, row, h_row[0]); cudaThreadSynchronize(); } // Номализовать строку normalizeRow<<< row_grid, threads>>> (d_matrix, width, row); cudaThreadSynchronize(); // Выполнить проход, обнуляя элементы над и под диагональю pass<<< grid, threads>>> (d_matrix, size, width, row); cudaThreadSynchronize(); } // Проверка, успешно ли выполнились ядра GPU cutilCheckMsg("Ошибка исполнения ядра."); cutilCheckError( cutStopTimer( timer)); printf( "Время поиска обратной матрицы: %f (ms)\n", cutGetTimerValue( timer)); cutilCheckError(cutDeleteTimer( timer)); if (error_code == 0) { // Выделение памяти для сохранения результатов float* h_matrix = (float*) malloc(memory_size); // Копирование результатов в оперативную память cutilSafeCall( cudaMemcpy( h_matrix, d_matrix, memory_size, cudaMemcpyDeviceToHost) ); //outMatrix = (float *) malloc(sizeof(float) * size * size); // Сохранение результатов for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { int out_index = i * size + j; int h_index = i * 2 * size + size + j; outMatrix[out_index] = h_matrix[h_index]; } } free( h_matrix); } free( matrix); free( h_row); cutilSafeCall(cudaFree(d_matrix));

Page 58: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

58

cutilSafeCall(cudaFree(d_row)); cudaThreadExit(); return error_code; } // Решение системы линейных уравнений с использованием GPU int GPUSloveSystemOfLinearEquations(float* matrix, float* outResult, int size) { // Код ошибки int error_code = 0; // Общая ширина матрицы (исходная + единичная). int width = size + 1; // Объем памяти необходимый для хранения матрицы. unsigned int memory_size = sizeof(float) * width * size; // Поиск устройства с максимальной производительностью на котором будут проведены все вычисления. cudaSetDevice( cutGetMaxGflopsDeviceId() ); // Инициализация таймера unsigned int timer = 0; cutilCheckError( cutCreateTimer( &timer)); cutilCheckError( cutStartTimer( timer)); // Инициализация памяти на устройстве float* d_matrix; int* d_row; cutilSafeCall( cudaMalloc( (void**) &d_matrix, memory_size)); cutilSafeCall( cudaMalloc( (void**) &d_row, sizeof(int))); // Копирование данных на устройство cutilSafeCall( cudaMemcpy( d_matrix, matrix, memory_size, cudaMemcpyHostToDevice) ); // Определение параметров исполнения. // Сетка покрывающая всю матрицу dim3 grid(width / BLOCK_DIM, size / BLOCK_DIM); // Потоки покрывающие весь блок dim3 threads(BLOCK_DIM, BLOCK_DIM, 1); // Сетка покрывающая один столбец матрицы dim3 column_grid(size / BLOCK_DIM); // Сетка из одного элемента dim3 single_grid(1); // Сетка покрывающая одну строку матрицы dim3 row_grid(width / BLOCK_DIM); // Единичный поток dim3 single_thread(1); // Временная переменная для обмена данными между CPU и GPU

Page 59: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

59

int* h_row = (int*) malloc(sizeof(int)); // Основной цикл. Приведение всех строк матрицы. for (int row = 0; row < size; row ++) { // Проверка, не стоит ли 0 элемент на диагонали. validateRows<<< single_grid, single_thread >>> (d_matrix, size, width, row, d_row); cudaThreadSynchronize(); // Копирование результата с GPU на CPU cutilSafeCall( cudaMemcpy( h_row, d_row, sizeof(int), cudaMemcpyDeviceToHost) ); // Матрицу невозможно привести к единичному виду - окончание цикла. if(h_row[0] == -1) { error_code = -1; break; // На диагонали нулевой элемент - нужно поменять местами. } else if (h_row[0] != row) { // Поменять местами две строки матрицы swapRows<<< row_grid, threads>>> (d_matrix, width, row, h_row[0]); cudaThreadSynchronize(); } // Номализовать строку normalizeRow<<< row_grid, threads>>> (d_matrix, width, row); cudaThreadSynchronize(); // Выполнить проход, обнуляя элементы над и под диагональю pass<<< grid, threads>>> (d_matrix, size, width, row); cudaThreadSynchronize(); } // Проверка, успешно ли выполнились ядра GPU cutilCheckMsg("Ошибка исполнения ядра."); cutilCheckError( cutStopTimer( timer)); printf( "Время решения системы линейных уравнений: %f (ms)\n", cutGetTimerValue( timer)); cutilCheckError(cutDeleteTimer( timer)); if (error_code == 0) { // Выделение памяти для сохранения результатов float* h_matrix = (float*) malloc(memory_size); // Копирование результатов в оперативную память cutilSafeCall( cudaMemcpy( h_matrix, d_matrix, memory_size, cudaMemcpyDeviceToHost) ); // Сохранение результатов

Page 60: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

60

for (int i = 0; i < size; i++) { int index = i * width + width - 1; outResult[i] = h_matrix[index]; } free( h_matrix); } free( matrix); free( h_row); cutilSafeCall(cudaFree(d_matrix)); cutilSafeCall(cudaFree(d_row)); cudaThreadExit(); return error_code; } // Вычисление детерминанта квадратной матрицы с использованием GPU int GPUDeterminant(float* matrix, int size, float* determinant) { // Код ошибки int error_code = 0; // Множитель, определяющий знак детерминанта. float result = 1.0; // Объем памяти необходимый для хранения матрицы. unsigned int memory_size = sizeof(float) * size * size; // Поиск устройства с максимальной производительностью на котором будут проведены все вычисления. cudaSetDevice( cutGetMaxGflopsDeviceId() ); // Инициализация таймера unsigned int timer = 0; cutilCheckError( cutCreateTimer( &timer)); cutilCheckError( cutStartTimer( timer)); // Инициализация памяти на устройстве float* d_matrix; int* d_row; cutilSafeCall( cudaMalloc( (void**) &d_matrix, memory_size)); cutilSafeCall( cudaMalloc( (void**) &d_row, sizeof(int))); // Копирование данных на устройство cutilSafeCall( cudaMemcpy( d_matrix, matrix, memory_size, cudaMemcpyHostToDevice) ); // Определение параметров исполнения. // Сетка покрывающая всю матрицу dim3 grid(size / BLOCK_DIM, size / BLOCK_DIM); // Потоки покрывающие весь блок

Page 61: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

61

dim3 threads(BLOCK_DIM, BLOCK_DIM, 1); // Сетка покрывающая один столбец матрицы dim3 column_grid(size / BLOCK_DIM); // Сетка из одного элемента dim3 single_grid(1); // Сетка покрывающая одну строку матрицы dim3 row_grid(size / BLOCK_DIM); // Единичный поток dim3 single_thread(1); // Временная переменная для обмена данными между CPU и GPU int* h_row = (int*) malloc(sizeof(int)); // Основной цикл. Приведение всех строк матрицы. for (int row = 0; row < size; row ++) { // Проверка, не стоит ли 0 элемент на диагонали. validateRows<<< single_grid, single_thread >>> (d_matrix, size, size, row, d_row); cudaThreadSynchronize(); // Копирование результата с GPU на CPU cutilSafeCall( cudaMemcpy( h_row, d_row, sizeof(int), cudaMemcpyDeviceToHost) ); // Матрицу невозможно привести к единичному виду - окончание цикла. if(h_row[0] == -1) { error_code = -1; break; // На диагонали нулевой элемент - нужно поменять местами. } else if (h_row[0] != row) { result = result * -1.0; // Поменять местами две строки матрицы swapRows<<< row_grid, threads>>> (d_matrix, size, row, h_row[0]); cudaThreadSynchronize(); } // Выполнить проход, обнуляя элементы над и под диагональю pass_determinant<<< grid, threads>>> (d_matrix, size, row); cudaThreadSynchronize(); } // Проверка, успешно ли выполнились ядра GPU cutilCheckMsg("Ошибка исполнения ядра."); cutilCheckError( cutStopTimer( timer)); printf( "Время поиска детерминанта: %f (ms)\n", cutGetTimerValue( timer)); cutilCheckError(cutDeleteTimer( timer)); if (error_code == 0) { // Выделение памяти для сохранения результатов

Page 62: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

62

float* h_matrix = (float*) malloc(memory_size); // Копирование результатов в оперативную память cutilSafeCall( cudaMemcpy( h_matrix, d_matrix, memory_size, cudaMemcpyDeviceToHost) ); // Сохранение результатов for (int i = 0; i < size; i++) { int index = i * size + i; result *= h_matrix[index]; } free( h_matrix); } free( matrix); free( h_row); cutilSafeCall(cudaFree(d_matrix)); cutilSafeCall(cudaFree(d_row)); cudaThreadExit(); *determinant = result; return error_code; }

Page 63: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

63

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

матрицами.

// Системные библиотеки #include <stdlib.h> #include <stdio.h> #include <string.h> #include <math.h> // CUDA Утилиты #include <cutil_inline.h> #include <GPUMatrix.h> int TestGPUInverse(float* inMatrix, float* outMatrix, int size); int main( int argc, char** argv) { TestGPUInverse(); cutilExit(argc, argv); } int TestGPUInverse() { unsigned int size = 512; // Выделение памяти под исходную матрицу float *matrix = (float *) malloc(sizeof(float) * size * size); // Заполнение матрицы произвольными значениями srand(2009); for (int i = 0; i < size; i++) { for (int j = 0; j < size; j++) { int index = i * size + j; &matrix[index] = (float) rand(); } } // Выделение памяти под обращенную матрицу float *inversed_matrix = (float *) malloc(sizeof(float) * size * size); // Выполнения функции GPUInverse из библиотеки. int error_code = GPUInverse(matrix, inversed_matrix, size); // Проверка корректности исполнения программы if (error_code != 0) { printf("Ошибка. Не удалось найти обратную матрицу."); return -1; } // Вывод результатов на экран. printf("GPU: Обратная матрица: \n"); for (int i = 0; i < size; i++)

Page 64: РЕШЕНИЕ ЗАДАЧ ОБЩЕГО НАЗНАЧЕНИЯ НА …inf.tsu.ru/library/DiplomaWorks/CompScience/2009/Homyuk/diplom.pdfЦель работы ± исследование

64

{ for (int j = 0; j < size; j++) printf("%.2f ", inversed_matrix[i * size + j]); printf("\n"); } return 0; }