248 65 7MB
Russian Pages 254 [255] Year 2020
д-р Бриан Тоуманен
Программирование GPU при помощи Python и CUDA
Hands-On GPU Programming with Python and CUDA
Explore high-performance parallel computing with CUDA
Dr. Brian Tuomanen
BIRMINGHAM – MUMBAI
Программирование GPU при помощи Python и CUDA
Исследуйте высокопроизводительные параллельные вычисления с помощью CUDA
д-р Бриан Тоуманен
Москва, 2020
УДК 004.438Python ББК 32.973.22 Т50
Т50
Тоуманен Б. Программирование GPU при помощи Python и CUDA / пер. с анг. А. В. Борескова. – М.: ДМК Пресс, 2020. – 254 с.: ил. ISBN 978-5-97060-821-0 Книга предлагает быстрое погружение в программирование GPU. Вы узнаете, как применять закон Амдала, использовать профилировщик для определения узких мест в коде на Python, настроить окружения для программирования GPU. По мере чтения вы будете запускать свой код на GPU и писать полноценные ядра и функции на CUDA C, научитесь отлаживать код при помощи NSight IDE и получите представление об известных библиотеках от NVIDIA, в частности cuFFT и cuBLAS. Вооружившись этими знаниями, вы сможете написать с нуля глубокую нейронную сеть, использующую GPU, и изучить более основательные темы. Книга предназначена для разработчиков и специалистов по обработке данных, которые хотят познакомиться с основами эффективного программирования GPU для улучшения быстродействия, используя программирование на Python. Желательно общее знакомство с базовыми понятиями математики и физики, а также опыт программирования на Python и любом основанном на С языке программирования.
УДК 004.438Python ББК 32.973.22
Authorized Russian translation of the English edition of Hands-On GPU Programming with Python and CUDA ISBN 9781789136678 © 2018 Packt Publishing. This translation is published and sold by permission of Packt Publishing, which owns or controls all rights to publish and sell the same. Все права защищены. Любая часть этой книги не может быть воспроизведена в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
ISBN 978-1-78899-391-3 (анг.) ISBN 978-5-97060-821-0 (рус.)
© 2018 Packt Publishing © Оформление, издание, перевод, ДМК Пресс, 2020
Содержание
Об авторе ...........................................................................................................10 О рецензенте ....................................................................................................11 Предисловие ....................................................................................................12 Глава 1. Почему программирование GPU?...........................................18 Технические требования ......................................................................................19 Параллелизация и закон Амдала .........................................................................19 Использование закона Амдала ........................................................................21 Множество Мандельброта................................................................................22 Профилировка вашего кода .................................................................................25 Использование модуля cProfile........................................................................25 Резюме ...................................................................................................................26 Вопросы .................................................................................................................27
Глава 2. Настройка окружения для программирования GPU ......28 Технические требования ......................................................................................29 Убедитесь, что у вас есть требуемое оборудование ...........................................29 Проверка вашего оборудования (Linux)..........................................................30 Проверка вашего оборудования (Windows) ....................................................31 Установка драйверов для GPU .............................................................................33 Установка драйверов GPU (Linux) ....................................................................33 Установка драйвера GPU (Windows) ................................................................35 Установка окружения для программирования на С++ .......................................35 Настройка GCC, Eclipse IDE и графических зависимостей (Linux) ................35 Установка Visual Studio (Windows) ..................................................................36 Установка CUDA Toolkit ....................................................................................38 Установка окружения Python для программирования GPU ..............................39 Установка PyCUDA (Linux) ................................................................................40 Создание скрипта для настройки окружения (Windows)...............................40 Установка PyCUDA (Windows) ..........................................................................41 Проверка PyCUDA .............................................................................................42 Резюме ...................................................................................................................42 Вопросы .................................................................................................................43
6
Содержание
Глава 3. Начало работы с PyCUDA ...........................................................44 Технические требования ......................................................................................44 Опрос вашего GPU ................................................................................................45 Опрос вашего GPU при помощи PyCUDA ........................................................46 Использование класса gpuarray модуля PyCUDA .................................................49 Перенос данных в и из GPU при помощи gpuarray .........................................49 Использование основных поэлементных операций через методы gpuarray...............................................................................................................50 Использование ElementWiseKernel из PyCUDA для выполнения поэлементных операций......................................................................................55 Возвращаемся к множеству Мандельброта ....................................................58 Краткая вылазка в функциональное программирование .............................61 Основа параллельного сканирования и редуцирования ...............................63 Резюме ...................................................................................................................64 Вопросы .................................................................................................................65
Глава 4. Ядра, нити, блоки и сетки ...........................................................66 Технические требования ......................................................................................67 Ядра .......................................................................................................................67 Функция SourceModule из PyCUDA ..................................................................67 Нити, блоки и сетки ..............................................................................................70 Игра «Жизнь» Джона Конвея ...........................................................................70 Синхронизация и взаимодействие нитей ..........................................................77 Использование функции устройства __syncthreads() .....................................77 Использование разделяемой памяти ..............................................................80 Алгоритм параллельной префиксной суммы.....................................................82 Алгоритм наивный параллельной префиксной суммы.................................82 Исключающая префиксная сумма и включающая префиксная сумма ........85 Эффективный алгоритм параллельной префиксной суммы ........................85 Эффективный алгоритм параллельной префиксной суммы (реализация) .....................................................................................................87 Резюме ...................................................................................................................89 Вопросы .................................................................................................................90
Глава 5. Потоки, события, контексты и одновременность.............91 Технические требования ......................................................................................92 Синхронизация устройства CUDA .......................................................................92 Использование класса stream из PyCUDA .......................................................93 Параллельная игра «Жизнь» Конвея при помощи потоков CUDA ................97 События ...............................................................................................................100 События и потоки ...........................................................................................102 Контексты............................................................................................................103 Синхронизация в текущем контексте ...........................................................104
Содержание 7 Создание контекста ........................................................................................105 Многопроцессность и многонитиевость на стороне хоста .........................106 Различные контексты для параллельности на стороне хоста .....................107 Резюме .................................................................................................................110 Вопросы ...............................................................................................................111
Глава 6. Отладка и профилирование вашего кода на CUDA ......112 Технические требования ....................................................................................113 Использование printf внутри ядер CUDA .........................................................113 Использование printf для отладки ................................................................115 Заполняем пробелы в CUDA C............................................................................119 Использование NSight IDE для разработки и отладки кода на CUDA C ..........124 Использование NSight c Visual Studio IDE под Windows .............................125 Использование NSight с Eclipse под Linux ....................................................128 Использование NSight для понимания варпа в CUDA .................................131 Использование профайлера nvprof и Visual Profiler ........................................134 Резюме .................................................................................................................136 Вопросы ...............................................................................................................136
Глава 7. Использование библиотек CUDA вместе со Scikit-CUDA .................................................................................137 Технические требования ....................................................................................138 Установка Scikit-CUDA ........................................................................................139 Базовая линейная алгебра при помощи cuBLAS ..............................................139 Функции 1-го уровня AXPY в cuBLAS ............................................................139 Другие функции cuBLAS 1-го уровня ............................................................141 GEMV 2-го уровня в cuBLAS ...........................................................................142 Функции 3-го уровня GEMM в cuBLAS для измерения производительности GPU ..............................................................................144 Быстрое преобразование Фурье при помощи cuFFT .......................................147 Простое одномерное FFT ...............................................................................148 Использование FFT для свертки ....................................................................149 Использование cuFFT для двумерной свертки .............................................150 Использование cuSolver из Scikit-CUDA ............................................................155 Сингулярное разложение (SVD).....................................................................155 Использование SVD для анализа методом главных компонент (PCA) .......156 Резюме .................................................................................................................158 Вопросы ...............................................................................................................158
Глава 8. Библиотеки функций для GPU CUDA и Thrust .................159 Технические требования ....................................................................................160 Библиотека функций GPU cuRAND....................................................................160
8
Содержание
Оценка π при помощи метода Монте-Карло................................................161 CUDA Math API ....................................................................................................165 Краткий обзор определенных интегралов ...................................................165 Вычисление определенного интеграла при помощи метода Монте-Карло ...................................................................................................166 Пишем тесты ...................................................................................................172 Библиотека CUDA Thrust ....................................................................................174 Использование функторов в Thrust ..............................................................176 Резюме .................................................................................................................178 Вопросы ...............................................................................................................178
Глава 9. Реализация глубокой нейросети ...........................................180 Технические требования ....................................................................................181 Искусственные нейроны и нейросети ..............................................................181 Реализация плотного слоя искусственных нейронов ..................................182 Реализация слоя мягкого максимума ...............................................................187 Реализация функции потери перекрестной энтропии ....................................189 Реализация последовательной сети ..................................................................189 Реализация методов вывода ..........................................................................191 Градиентный спуск .........................................................................................193 Подготовка и нормализация данных ............................................................197 Данные Iris ..........................................................................................................197 Резюме .................................................................................................................200 Вопросы ...............................................................................................................200
Глава 10. Работа с компилированным кодом для GPU ................201 Запуск откомпилированного кода при помощи Ctypes ...................................202 Снова возвращаемся к вычислению множества Мандельброта .................202 Компиляция и запуск PTX-кода.........................................................................208 Написание «оберток» для CUDA Driver API .......................................................209 Использование CUDA Driver API ....................................................................213 Резюме .................................................................................................................216 Вопросы ...............................................................................................................217
Глава 11. Оптимизация быстродействия в CUDA ............................218 Динамический параллелизм .............................................................................219 Быстрая сортировка при помощи динамического параллелизма ..............220 Векторные типы данных и доступ к памяти.....................................................222 Потокобезопасные атомарные операции .........................................................224 Перестановки в пределах варпа ........................................................................225 Вставка PTX-ассемблера прямо в код................................................................228
Содержание
9
Оптимизированная по быстродействию версия суммирования элементов массива ................................................................................................................232 Резюме .................................................................................................................235 Вопросы ...............................................................................................................235
Глава 12. Куда идти далее? ......................................................................237 Расширение знаний о CUDA и программировании GPGPU .............................238 Системы из нескольких GPU ..........................................................................238 Кластерные вычисления и MPI ......................................................................238 OpenCL PyOpenCL ...........................................................................................239 Графика ...............................................................................................................239 OpenGL ............................................................................................................240 DirectX12 .........................................................................................................240 Vulkan ..............................................................................................................240 Машинное обучение и компьютерное зрение .................................................241 Основы ............................................................................................................241 cuDNN ..............................................................................................................241 Tensorflow и Keras ...........................................................................................242 Chainer .............................................................................................................242 OpenCV ............................................................................................................242 Технология блокчейн..........................................................................................242 Резюме .................................................................................................................243 Вопросы ...............................................................................................................243
Ответы на вопросы......................................................................................244 Предметный указатель ..............................................................................250
Об авторе Доктор Бриан Тоуманен работал с CUDA и программированием GPU с 2014 г. Он получил степень бакалавра по специальности «Электроинженерия» в университете Вашингтона в Сиэтле, затем некоторое время являлся разработчиком программного обеспечения, после чего продолжил обучение в области математики. В университете Миссури (Колумбия) Бриан защитил кандидатскую диссертацию по математике, где впервые столкнулся с программированием GPU для решения научных задач. Доктор Тоуманен был приглашен в исследовательскую лабораторию министерства армии США по программированию GPU для выяснения вопросов общего назначения и не так давно руководил интеграцией GPU и разработкой стартапа в Мэриленде. Сейчас он работает в качестве специалиста по машинному обучению (Azure CSI) в компании Microsoft в Сиэтле. Я бы хотел поблагодарить профессора Мишеллу Беччи из отдела NCSU ECE и ее студента Эндрю Тодда за помощь, оказанную мне как начинающему программис ту GPU в 2014 г. Также хочу выразить особую признательность моему редактору в Packt Акшаде Иер за содействие в написании данного труда и, наконец, профес сору Андреасу Клекнеру за составление великолепной библиотеки PyCUDA, кото рую я активно использовал в своей книге.
О рецензенте Вандане Шах была присуждена степень бакалавра по специальности «Электроэнергетика и электротехника» в 2001 г. Она также приобрела навыки MBA «Управление персоналом» и магистерскую степень по электронике со специализацией в области разработки VLSI. Также ею была представлена работа на получение кандидатской степени по специальности «Электроника», в частности в области обработки изображений и глубокого обучения для диагностики опухоли мозга. Областями интересов Ванданы являются обработка изображений, а также встраиваемые системы. Имеет более 13 лет опыта в исследованиях, а также в обучении и подготовке студентов по дисциплине «Электроника и связь». Ею было опубликовано множество работ в уважающих себя журналах, таких как IEEE, Springer и Interscience. Она также получила государственный грант для проведения исследований в области обработки изображений в MRI. Кроме того что Вандана прекрасно разбирается в вопросах техники, она неплохо владеет искусством индийского танца катхак. Я благодарю членов моей семьи за их поддержку во всем.
Предисловие Приветствую вас и поздравляю! Эта книга является вводным курсом по программированию GPU с использованием Python и CUDA. GPU обычно обозначает Graphics Programming Unit (Графический процессор), но здесь пойдет речь не о программировании графики – она является введением в программирование общего вида на GPU или программирование GPGPU (General Purpose GPU). За последнее десятилетие стало очевидным, что GPU хорошо подходят для вычислений не только графических изображений, но параллельно с этим для тех, которые одновременно требуют высокой пропускной способности. Для этого NVIDIA выпустила CUDA Toolkit, благодаря чему область программирования GPGPU стала более доступной практически для любого человека, хоть немного знакомого с некоторыми знаниями языка программирования. Целью книги «Программирование GPU при помощи Python и CUDA» является огромное желание как можно быстрее ввести читателя в мир технологий GPGPU. Я старался подать материал так, чтобы в каждой главе можно было встретить не только интересные примеры и упражнения, но и прочесть о некоторых веселых случаях. В частности, вы можете набирать приведенные упражнения и выполнять их в вашей любимой среде Python (могут подойти Spyder, Jupiter и PyCharm). Таким образом, вы с легкостью запомните все необходимые функции и команды и одновременно получите некоторый опыт, как при помощи интуиции могут быть написаны программы для GPGPU. Поначалу параллельное программирование для GPGPU кажется довольно сложным и несколько обескураживающим, особенно если ранее вы соприкасались только с программированием CPU. Вам придется выучить так много новых понятий и соглашений, что иногда будет казаться, словно вы видите это в первый раз. В такие моменты лучше не отчаиваться, а верить, что все приложенные усилия по освоению данной области не напрасны. Обещаю, что при некоторой любознательности и соблюдении дисциплины столь загадочная область, по мере того как вы дойдете до конца книги, станет как бы вашей второй сущностью.
Для кого эта книга Эта книга предназначена прежде всего для одного особенного человека – меня в 2014 г., когда я пытался написать программу для моделирования при помощи GPU для своей кандидатской диссертации по математике. Мне приходилось часами просиживать над многочисленными книгами и руководствами по программированию GPU, пытаясь отыскать хоть малейший смысл во всем этом;
О чем рассказывает эта книга? 13 большинство книг, казалось, представляло собой сплошной парад аппаратных схем и непонятных слов на каждой странице, в то время как само программирование оставалось где-то на заднем плане. Также моя книга адресована тем, кто на самом деле мечтает по-серьезному заняться программированием GPU, но без влезания в многочисленные технические детали и схемы аппаратуры. Мы будем программировать GPU на С/С++ (CUDA C), но при этом использовать Python при помощи модуля PyCUDA. PyCUDA позволяет писать лишь действительно необходимый низкоуровневый код для GPU, причем данный модуль призван самостоятельно отвечать за всю необходимую компиляцию, линковку и запуск кода на GPU для нас.
о чем рассказывает эта книга? Глава 1 «Почему программирование GPU?» объясняет, для чего нам необходимо разбираться в данных вопросах и как правильно применить закон Амдала для оценки потенциального выигрыша в быстродействии от перевода последовательной программы на GPU. Глава 2 «Настройка окружения для программирования GPU» объясняет, как настроить соответствующее окружение для Python и C++ для использования CUDA под Windows и Linux. Глава 3 «Начало работы с PyCUDA» описывает технические навыки, которые вам понадобятся для программирования GPU при помощи Python. В частности, мы увидим, как копировать данные в и из GPU при помощи класса gpuarray и компилировать простейшие ядра при помощи функции PyCUDA ElementwiseKernel. Глава 4 «Ядра, нити, блоки и сетки» обучит основам написания эффективных ядер CUDA, которые являются параллельными функциями, выполняемыми на GPU. Мы увидим, как писать выполняемые на GPU функции («последовательные» функции, вызываемые непосредственно ядрами CUDA), и познакомимся со структурой сетка/блок CUDA и ее ролью в запуске ядер. Глава 5 «Потоки, события, контексты и одновременность» покрывает такие понятия, как «потоки CUDA», которые позволяют одновременно запускать и синхронизировать на GPU множество ядер. Мы увидим, как использовать события CUDA для замера времени выполнения ядер и как создавать и использовать контексты CUDA. Глава 6 «Отладка и профилирование вашего кода на CUDA» заполнит некоторые пробелы в области чистого CUDA C программирования и покажет, как использовать NVIDIA NSight IDE для отладки и разработки, а также как использовать средства профилирования от NVIDIA. Глава 7 «Использование библиотек CUDA вместе со ScikitCUDA» дает краткий обзор некоторых важных библиотек CUDA при помощи модуля Scikit-CUDA, включая cuBLAS, cuFFT и cuSOLVER.
14
Предисловие
Глава 8 «Библиотеки функций для GPU CUDA и Thrust» покажет, как использовать cuRAND и функции математического API CUDA в вашем коде, а также как использовать контейнеры CUDA Thrust в коде на С++. Глава 9 «Реализация глубокой нейросети» служит основой, на которой мы построим с самого начала глубокую нейросеть, применяя многие из идей, которые разбирались в книге. Глава 10 «Работа с компилированным кодом для GPU» покажет, как связывать наш код на Python с заранее откомпилированным кодом для GPU при помощи PyCUDA и Ctypes. Глава 11 «Оптимизация быстродействия в CUDA» научит ряду низкоуровневых приемов для CUDA, таких как перестановка в пределах варпа (warp shuf fling), векторизованный доступ к памяти, использование PTX и атомарные операции. Глава 12 «Куда идти далее?» является обзором некоторых направлений, которые помогут вам развить ваши навыки программирования GPU.
как получить максимум от этой книги Это уже техническая тема. Будут сделаны лишь некоторые предположения относительно уровня программирования читателя. Мы будем считать, что: у вас средний уровень программирования на Python; вы знакомы со стандартными научными пакетами для Python, такими как NumPy, SciPy и Matplotlib; вы обладаете средним уровнем в любом С-подобном языке (С, С++, Java, Rust, Go и т. п.); вы понимаете динамическое выделение памяти в С (в частности, как использовать функции malloc и free). Программирование GPU применимо в различных научных областях, где чаще всего требуются знания математики, поэтому при приведении множества (если не большинства) примеров будут использоваться ее основы. Я предполагаю, что читатель знаком с программой первого или второго курса высшей математики, включая: тригонометрию (такие функции, как sin, cos, tg…); вычисления (интегралы, производные, градиенты); статистику (равномерное и нормальное распределение); линейную алгебру (векторы, матрицы, векторные пространства, размерность). Не беспокойтесь, если вы не выучили некоторые из этих тем или проходили их очень давно, по ходу книги мы будем рассматривать основные понятия программирования и математические понятия.
Также хочется сделать еще одно предположение. Если вы помните, в начале книги я говорил, что мы будем работать только с CUDA, которая является про-
Используемые соглашения 15 приетарным языком программирования для оборудования NVIDIA. Так что для начала вам необходимо наличие соответствующего оборудования. Поэтому я считаю, что у читателя есть доступ к: 64-битовому компьютеру на базе процессора от Intel/AMD; не менее 4 Гб оперативной памяти; GPU NVIDIA GTX 1050 или более усовершенствованная. Читателю также следует знать, что многие прежние версии GPU, скорее всего, будут пригодны для большинства, если не для всех, примеров, приведенных в книге, но тем не менее все они были проверены на GTX 1050 под Windows 10 и GTX 1070 под Linux. Конкретные инструкции по настройке и конфигурации приводятся в главе 2 «Настройка окружения для программирования GPU».
используемые соглашения На протяжении всей книги будут использоваться следующие соглашения. CodeInText обозначает текст программы, имена таблиц баз данных, имена каталогов, файлов, расширения файлов, пути, URL, ввод пользователя и т. п. Например: «Теперь мы можем использовать функцию cublasSaxpy». Блок кода выглядит следующим образом: cublas.cublasDestroy(handle) print 'cuBLAS returned the correct value: %s' % np.allclose(np.dot(A,x), y_gpu.get())
Когда мне хочется привлечь ваше внимание к определенному участку кода, я соответствующие строки выделяю жирным шрифтом: def compute_gflops(precision='S'): if precision=='S': float_type = 'float32' elif precision=='D': float_type = 'float64' else: return -1
Любые команды показываются следующим образом: $ run cublas_gemm_flops.py
Полужирное выделение обозначает новое понятие, важное слово или слова, которые вы видите на экране. Например, слова в меню или диалоговых окнах показываются таким образом. Предупреждения или важные замечания показываются таким образом. Подсказки или приемы показываются таким образом.
16
Предисловие
отзывы и пожелания Мы всегда рады отзывам наших читателей. Расскажите нам, что вы думаете об этой книге – что понравилось или, может быть, не понравилось. Отзывы важны для нас, чтобы выпускать книги, которые будут для вас максимально полезны. Вы можете написать отзыв на нашем сайте www.dmkpress.com, зайдя на страницу книги и оставив комментарий в разделе «Отзывы и рецензии». Также можно послать письмо главному редактору по адресу [email protected]; при этом укажите название книги в теме письма. Если вы являетесь экспертом в какой-либо области и заинтересованы в написании новой книги, заполните форму на нашем сайте по адресу http://dmkpress.com/authors/publish_book/ или напишите в издательство по адресу [email protected].
скачивание исхоДного коДа примеров Скачать файлы с дополнительной информацией для книг издательства «ДМК Пресс» можно на сайте www.dmkpress.com на странице с описанием соответствующей книги. Пакет кода для книги также размещен на GitHub на https://github.com/PacktPublishing/Generative-Adversarial-Networks-Projects. В случае обновления кода он будет обновлен в существующем репозитории GitHub. У нас есть другие комплекты кода из нашего богатого каталога книг и видео, доступных по адресу: http://github.com/PacktPublishing/. Проверьте их!
скачивание цветных изображений Вам также предоставляется PDF-файл, который содержит цветные изображения/диаграммы, используемые в книге. Скачать его вы можете на сайте http:// www.packt.com/sites/default/files/downloads/9781788993913_ColorImages.pdf.
список опечаток Хотя мы приняли все возможные меры для того, чтобы обеспечить высокое качество наших текстов, ошибки все равно случаются. Если вы найдете ошибку в одной из наших книг – возможно, ошибку в основном тексте или программном коде, – мы будем очень благодарны, если вы сообщите нам о ней. Сделав это, вы избавите других читателей от недопонимания и поможете нам улучшить последующие издания этой книги. Если вы найдете какие-либо ошибки в коде, пожалуйста, сообщите о них главному редактору по адресу [email protected], и мы исправим это в следующих тиражах.
Нарушение авторских прав 17
нарушение авторских прав Пиратство в интернете по-прежнему остается насущной проблемой. Издательства «ДМК Пресс» и Packt очень серьезно относятся к вопросам защиты авторских прав и лицензирования. Если вы столкнетесь в интернете с незаконной публикацией какой-либо из наших книг, пожалуйста, пришлите нам ссылку на интернет-ресурс, чтобы мы могли применить санкции. Ссылку на подозрительные материалы можно прислать по адресу электронной почты [email protected]. Мы высоко ценим любую помощь по защите наших авторов, благодаря которой мы можем предоставлять вам качественные материалы.
Глава
1 Почему программирование GPU?
Оказывается, что кроме непосредственно рендеринга графические процессоры (GPU, Graphics Processing, Unit) также предоставляют обычным пользователям возможность заниматься массивнопараллельными вычислениями – обычный человек может купить современный GPU за $2000 в магазине электроники, воткнуть в домашний компьютер и сразу же начать использовать всю вычислительную мощность, которая еще 5–10 лет назад была доступна только в лабораториях суперкомпьютерного моделирования крупнейших корпораций и университетов. Доступность GPU в последние годы сделалась намного заметнее, например добытчики применяют эти процессоры для майнинга таких криптовалют, как биткойн. Генетики и биологи при помощи GPU анализируют ДНК и проводят различные исследования, физикам и математикам они необходимы для широкомасштабного моделирования, исследователи в области искусственного интеллекта могут программировать GPU для написания пьес и музыки, крупные интернет-компании, такие как Google и Facebook, используют фермы серверов с GPU для выполнения сверхзадач в машинном обучении… Данный список можно продолжать и продолжать. Эта книга в первую очередь написана для того, чтобы как можно быстрее познакомить вас с программированием GPU и научить правильно задействовать их вычислительные возможности независимо от того, является ли это конечной целью. Я собираюсь говорить об основах программирования GPU вместо разбора технических деталей о том, как они работают. Ближе к концу книги вам будут предоставлены ссылки на онлайн-ресурсы и дополнительные источники, благодаря чему у вас появится прекрасная возможность применить полученные знания на практике. (Всю необходимую информацию о требуемых технических знаниях и оборудовании вы найдете в конце этого раздела.) Также мы будем работать с CUDA – библиотекой для расчетов общего назначения на GPU (GPGPU) от NVIDIA, выпустившей ее в 2007 г. Являясь довольно зрелой и устойчивой платформой, которую легко использовать, она предоставляет несравнимый с другими набор первоклассных математических и AI-
Параллелизация и закон Амдала 19 библиотек, не сравнимый с другими, крайне простой в установке и интеграции. Кроме того, существуют стандартные и легкодоступные библиотеки для Python, такие как PyCUDA и SciKit-CUDA, которые делают программирование GPU доступным даже для начинающих. Именно по этим причинам мы выбрали использовать CUDA в данной книге. CUDA всегда произносится как ку-да и никогда как C-U-D-A! Изначально CUDA было сокращением от Compute Unified Device Architecture, но в дальнейшем NVIDIA отказалась от столь длинного названия, и теперь CUDA употребляется в качестве обычного слова, записанного заглавными буквами.
Мы начнем наше путешествие в программирование GPU с обзора закона Амдала, который является довольно простым и эффективным способом оценить потенциальный выигрыш от переноса программы или алгоритма на GPU. Он поможет четко определить, стоит ли нам переписывать код для использования GPU. Далее, для того чтобы определить узкие места в программе, мы вкратце рассмотрим профилирование кода на Python при помощи модуля cProfile. В результате изучения этой главы вы сможете: понимать закон Амдала; применять закон Амдала в контексте вашего кода; использовать модуль cProfile для базового профилирования кода на Python.
технические требования Для этой главы предлагается использовать установленную Anaconda Python 2.7: https://www.anaconda.com/download/. Код доступен на GitHub: https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA. За дополнительной информацией о требованиях обратитесь к предисловию к этой книге; о требованиях к программному обеспечению и оборудованию прочтите README в https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA.
параллелизация и закон амДала Прежде чем мы приступим к раскрытию потенциала GPU, нам следует понять, как их вычислительная мощность соотносится с современными процессорами от Intel/AMD – мощность заключается не в более высокой тактовой частоте, как у CPU или более сложных ядер. На самом деле отдельное ядро GPU простое и уступает ядру традиционного CPU, использующего многие хитрые приемы, такие как предсказание ветвления, для уменьшения латентности вычислений. Латентность означает время, затраченное на вычисление, от начала и до конца.
20
Почему программирование GPU?
Мощность GPU заключается в том, что в нем намного больше ядер, чем в традиционном CPU, что означает мощный прорыв в пропускной способности (throughput). Пропускная способность есть количество вычислений, которые могут быть выполнены одновременно. Давайте рассмотрим аналогию, чтобы лучше понять, что это за процесс. GPU можно сравнить с очень широкой дорогой, предназначенной для огромного количества медленно двигающихся машин одновременно (высокая пропускная способность, высокая латентность), в то время как CPU – это узкий хайвей, на котором разрешено движение ограниченного числа машин только в одну сторону, но каждая из них может ехать очень быстро (низкая пропускная способность, низкая латентность). Мы можем получить представление об увеличении пропускной способности, всего лишь рассмотрев, сколько ядер вмещает в себя GPU. Обычно CPU от Intel/AMD содержит от двух до восьми ядер, в то время как базовый GPU низкого уровня NVIDIA GTX 1050 – 640 ядер, а топовый NVIDIA RTX 2080 Ti – 4352 ядра! Мы можем использовать столь массивную пропускную способность при условии, что нам известно, как правильно распараллелить любую программу или алгоритм, которые мы хотим ускорить. Под распараллеливанием мы подразумеваем переписывание программы или алгоритма таким образом, чтобы всю нашу работу одновременно можно было выполнить сразу на большом количестве процессоров (ядер). И вновь напрашивается аналогия из обычной жизни. Представьте, что вы строите дом, проект готов и закуплены все необходимые материалы. Вы нанимаете одного рабочего и оцениваете, что для постройки здания понадобится минимум 100 ч. Теперь представьте, что дом можно возвести таким образом, что вся работа будет равномерно распределяться между нанятыми рабочими – тем самым потребуется 50 ч для двух рабочих, 25 – для четырех, 10 – для десяти. Число часов для постройки дома будет равно 100, поделенное на число рабочих. Это пример распараллеливаемой задачи. Таким образом, мы видим, что постройка будет идти вдвое быстрее для двух и в десять раз быстрее для десяти человек, работающих вместе (т. е. параллель но). Вывод такой: если у нас N рабочих, то строительство будет в N раз быстрее. В этом случае N называют ускорением от параллелизации по сравнению с последовательной версией задачи. Прежде чем приступить к программированию параллельной версии алгоритма, как правило, лучше всего начать с оценки потенциального ускорения, которое даст параллелизация. Это поможет нам определить, стоит ли тратить ресурсы и время на распараллеливание данной программы. Поскольку реальная жизнь гораздо сложнее приведенного примера, то становится понятным, что мы не сможем распараллелить каждую программу целиком – скорее всего, только часть нашей программы будет хорошо распараллеливаться, в то время как оставшуюся придется выполнять последовательно.
Параллелизация и закон Амдала 21
Использование закона Амдала Теперь мы выведем закон Амдала, который является простой арифметической формулой, что используется для оценивания потенциального ускорения от распараллеливания части кода из последовательной программы на ряд процессоров. Мы проделаем это, продолжая нашу предыдущую аналогию со строительством дома. В прошлый раз мы рассмотрели только процесс реального физического строительства дома как один интервал времени, но сейчас нас интересует еще и время, требуемое для его проектирования как часть строительства. Допустим, что лишь один человек во всем мире может спроектировать ваш дом – вы – и требуется 100 ч для создания генерального плана. Вы прекрасно понимаете, что никто другой на планете не сможет сравниться с вашим архитектурным мастерством, поэтому нет никакого шанса, что часть этой работы может быть поделена со сторонними архитекторами. Разработка проекта вашего дома займет ровно 100 ч, независимо от того, каким количеством ресурсов вы располагаете на данный момент или сколько человек вы можете нанять. Поэтому если в строительстве дома у вас задействован всего лишь один рабочий, то время, затраченное на возведение здания, будет равно 200 ч – 100 ч на проектирование и 100 ч для выполнения одним рабочим всех строительных работ. Если нанять двух рабочих – время, соответственно, увеличится до 100–150 ч на проектирование, но вот само строительство уже займет 50 ч. Таким образом, общее время, затраченное на постройку дома, будет равно 100 + 100/N, где N – число нанятых рабочих. Теперь вернемся назад и посмотрим, сколько времени уйдет на строительство, если мы наймем всего лишь одного рабочего – мы будем рассматривать его как основу для вычисления ускорения при найме дополнительных рабочих. Если мы нанимаем одного, то видим, что строительство дома занимает столько же времени, сколько и его проектирование, – 100 ч. Поэтому мы с полной уверенностью можем сказать, что часть времени, затраченная на проектирование, равна 0,5 (50 %), и часть времени, затраченная на строительство, также равна 0,5 (50 %). При этом в сумме эти две части дают 1, т. е. 100 %. Что же произойдет, если нам придется нанять дополнительных рабочих? Когда у нас есть двое рабочих, то время, затраченное на строительство, также уменьшается вдвое. Поэтому по сравнению с последовательной версией нам потребуется 0,5 + 0,5/2 = 0,75 (75 %) времени для решения исходной задачи и 0,75 × 200 ч = 150 ч. То есть мы видим, как это работает на деле. Также мы понимаем, что если наймем N рабочих, то сможем вычислить долю времени по формуле 0,5 + 0,5/N. Теперь давайте определим ускорение, которое мы получаем, нанимая дополнительных рабочих. Поскольку необходимо 75 % времени, если в нашем распоряжении имеются двое рабочих, то мы возьмем обратное к 0,75 для получения ускорения от нашего распараллеливания. Итак, ускорение будет равно 1/0,75, и процесс постройки дома пойдет примерно в 1,33 раза быстрее, чем если бы
22
Почему программирование GPU?
мы располагали силой всего одного рабочего. Таким образом, если у нас есть N рабочих, то ускорение будет 1/(0,5 + 0,5/N). Мы знаем, что 0,5/N по мере добавления новых рабочих будет быстро приближаться к нулю. Поэтому можно получить верхнюю границу на ускорение, которое мы получим от распараллеливания данной задачи, – 1/(0,5 + 0) =2. При делении исходного последовательного времени на полученное максимальное ускорение эта задача займет 200/2 = 100. Подход, который только что был нами применен, в параллельном программировании называется законом Амдала. Он требует лишь хорошего знания части программы, которая может быть распараллелена в нашей исходной последовательной программе, которую мы обозначим через p, и числа доступных ядер N. Доля времени выполнения, которая не параллелизуется, в этом случае всегда будет равна 1 – p.
Мы можем вычислить ускорение при помощи закона Амдала следующим образом: Speedup = 1/((1 – p) + p/N). Подводя итоги вышесказанному, хочется упомянуть, что закон Амдала – простая формула, которая позволяет нам примерно (очень примерно) оценить потенциальное ускорение от распараллеливания программы. Это может помочь нам определить, стоит ли писать параллельную версию конкретной последовательной программы, при условии что нам известно, какую часть кода можно распараллелить (p) и на каком числе ядер (N) будет выполняться параллельный код.
Множество Мандельброта Теперь мы готовы рассмотреть простейший пример параллельных вычислений, к которому вернемся в книге немного позднее, – алгоритм для построения изображения множества Мандельброта. Для начала давайте определимся, что мы имеем в виду. Для заданного комплексного числа c мы определим рекурсивную последовательность для n ≤ 0 c z0 = 0 и zn = z 2n–1 + c для n > 1. Если |zn| остается не больше 2 по мере увеличения n до бесконечности, то в этом случае можно считать, что c принадлежит множеству Мандельброта. Мы можем представить комплексные числа в виде точек на двухмерной плоскости, при этом x-координата будет соответствовать вещественной части, а y – мнимой. Таким образом, множество Мандельброта выводится при помощи хорошо известной картинки. На ней мы изобразим точки, принадлежащие множеству при помощи более светлого оттенка, а остальные – при помощи более темного следующим образом:
Параллелизация и закон Амдала 23
Теперь давайте подумаем над тем, как мы можем построить это множество на Python. Сначала нам нужно рассмотреть следующее. Так как у нас нет возможности проверить каждое комплексное число на принадлежность множеству Мандельброта, то необходимо выбрать определенный диапазон и для него уже определить нужное количество точек (width, height). Также нам понадобится максимальное число n, для которого мы будем проверять |zn| (max_iters). Теперь мы можем подготовиться для написания функции для построения изображения множества Мандельброта – для этого последовательно проверим каждую точку. Мы начнем с импорта NumPy – библиотеки для вычислений, которую будем использовать на протяжении всей книги. Наша реализация – это функция simple_mandelbrot. Для начала мы возьмем функцию linspace из NumPy для создания решетки точек, которая выступит в качестве дискретной комплексной плоскости (остальной код довольно прямолинеен). import numpy as np def simple_mandelbrot(width, height, real_low, real_high, imag_low, imag_high, max_iters): real_vals = np.linspace(real_low, real_high, width) imag_vals = np.linspace(imag_low, imag_high, height) # мы будем предствлять члены множества как 1, не члены – как 0. mandelbrot_graph = np.ones((height,width), dtype=np.float32) for x in range(width): for y in range(height): c = np.complex64( real_vals[x] + imag_vals[y] * 1j ) z = np.complex64(0) for i in range(max_iters): z = z**2 + c if(np.abs(z) > 2): mandelbrot_graph[y,x] = 0 break return mandelbrot_graph
24
Почему программирование GPU?
Чтобы сохранить множество Мандельброта в файл в формате PNG, необходимо добавить код. Для этого предлагаю вам в начало кода вставить соответствующие заголовки: from time import time import matplotlib # Следующий код не позволит «выскочить» картинке. matplotlib.use('Agg') from matplotlib import pyplot as plt
Теперь добавим код для создания множества Мандельброта и сохранения его в файл и используем функцию time для замера затраченного времени: if __name__ == '__main__': t1 = time() mandel = simple_mandelbrot(512,512,-2,2,-2,2,256, 2) t2 = time() mandel_time = t2 - t1 t1 = time() fig = plt.figure(1) plt.imshow(mandel, extent=(-2, 2, -2, 2)) plt.savefig('mandelbrot.png', dpi=fig.dpi) t2 = time() dump_time = t2 - t1 print 'Потребовалось {} секунд для расчета множества Мандельброта.'.format(mandel_time) print 'Потребовалось {} секунд для сохранения графика.'.format(dump_time)
После этого запустим полученную программу (она доступна как файл mandelbrot0.py в папке 1 в репозитории на GitHub):
Потребовалось 14,62 с для расчета множества Мандельброта и 0,11 с для его сохранения на диск. Как вы уже видели, мы рассчитываем множество Мандельброта точка за точкой; нет никаких взаимозависимостей между значениями в различных точках. Таких образом, это легко распараллеливаемая функция. В отличие от нее, код для сохранения изображения не может быть распараллелен. Теперь проанализируем это в терминах закона Амдала. Какой выигрыш по скорости мы можем получить от распараллеливания данного кода? В сумме оба фрагмента кода заняли на выполнение 14,73 с. Из всего этого можно сделать вывод, что доля кода, который будет распараллелен, равна p = 14,62/14,73 = 0,99. Эта программа, распараллеливаемая на 99 %. Какое ускорение мы можем теоретически получить? Сейчас я работаю на лэптопе с GPU GTX 1050 с 640 ядрами. Поэтому наше N = 640. Ускорение рассчитывается по следующей формуле:
Профилировка вашего кода 25 Все это означает, что наш алгоритм, безусловно, стоит переделать для использования GPU. Имейте в виду, что закон Амдала дает лишь очень приблизительную оценку! При переносе расчетов на GPU будут также учитываться дополнительные моменты, такие как время для CPU на передачу данных на GPU и из них, или тот факт, что алгоритмы, переносимые на GPU, лишь частично распараллеливаемы.
профилировка вашего коДа Из предыдущего примера мы увидели, что время, затраченное на выполнение различных функций и установку компонентов, можно замерять при помощи функции time в Python. Хотя этот подход подошел для нашей небольшой программки, он не всегда будет удобен и возможен для больших программ с многочисленными функциями, некоторые из которых стоит или не стоит распараллеливать или хотя бы просто оптимизировать на CPU. Нашей целью является найти узкие места программы. Даже если мы полны энергии и используем time для замера каждой функции, мы легко можем пропустить что-то, или могут быть вызовы системных библиотек, о которых мы даже не подумали, но они здорово замедляют работу программы. Необходимо определить фрагменты кода для переноса на GPU, прежде чем начать думать о переписывании его для GPU. Нам всегда нужно следовать мудрым словам известного американского ученого Дональда Кнута: «Преждевременная оптимизация есть источник всевозможного зла». Для нахождения узких мест в нашей программе мы будем использовать профайлер, который позволит в удобной форме увидеть, где именно программа тратит больше всего времени, и поможет ее оптимизировать в соответствии с полученной информацией.
Использование модуля cProfile Для проверки нашего кода мы в основном будем использовать модуль cProfile. Это стандартная библиотечная функция, которая содержится в каждой установке Python. Его можно вызвать из командной строки при помощи -m cProfile и задать способ организации результатов профилировки, которые будут отсортированы в соответствии с затраченным временем, при помощи опции -s cumtime. После чего перенаправить вывод в текстовый файл при помощи стандартного оператора >. Это будет работать и под Linux в bash, и под Windows в PowerShell.
Давайте именно сейчас попробуем это сделать.
26
Почему программирование GPU?
Теперь можно посмотреть на содержимое текстового файла при помощи своего любимого редактора. Имейте в виду, что вывод программы также будет включен и помещен в начало файла:
Итак, поскольку мы не убрали наши вызовы time, то сможем прочесть соответствующий вывод в первых двух строчках файла. Далее мы можем увидеть общее число вызовов функций в программе и суммарно потраченное на них время. Перед нами появится список всех функций, которые ранее вызывались в программе, упорядоченный от потративших наибольшее время к потратившим наименьшее. Первая строка и есть сама программа, а вторая, как и ожидалось, является функцией simple_mandelbrot. (Обратите внимание, что указанное для нее время согласуется с результатами замеров при помощи функции time.) Далее мы можем увидеть многочисленные библиотечные и системные вызовы, связанные с сохранением множества Мандельброта в файл, которые занимают сравнительно мало времени. Подобный вывод cProfile будет использован для определения того, где в нашей программе находятся узкие места.
резюме Основным преимуществом использования GPU по сравнению с CPU является увеличенная пропускная способность, которая означает, что мы можем выполнять больше кода параллельно на GPU, чем на CPU. GPU не способен сделать рекурсивные или нераспараллеливаемые алгоритмы быстрее, чем они есть. Мы с вами увидели, что некоторые задачи, например строительство дома, являются лишь частично распараллеливаемыми – отталкиваясь от приведенного примера, нам не удастся ускорить процесс проектирования дома (который по своей сути последователен), но мы можем ускорить процесс строительства,
Вопросы 27 просто нанимая большее количество рабочих (этот процесс является распараллеливаемым). Мы использовали эту аналогию для вывода закона Амдала, который является формулой, способной дать грубую оценку возможного ускорения программы, если нам известны доля времени для выполнения той ее части, которая распараллеливаема, и число процессоров, что мы будем использовать для реализации этого кода. Далее мы применили закон Амдала для анализа простой программы, которая вычисляет множество Мандельброта и сохраняет его в файл с изображением, и определили, что она будет хорошим кандидатом для распараллеливания на GPU. Наконец, мы привели краткий обзор профилировки кода при помощи модуля cProfile, что позволило нам увидеть узкие места в нашей программе, не прибегая к явным замерам времени выполнения. Итак, после того как мы сумели разобраться с рядом фундаментальных понятий и получить мотивацию для изучения программирования GPU, следующая глава будет посвящена рассмотрению настройки окружения для программирования GPU на Linux или Windows 10. После этого мы сразу же перейдем к программированию GPU, начав с написания GPU-версии программы для расчета множества Мандельброта, которая была приведена ранее.
вопросы 1. В примере с расчетом множества Мандельброта из этой главы есть три оператора for, но мы можем распараллелить только первые два. Почему мы не можем распараллелить все операторы? 2. Что закон Амдала не учитывает, когда мы переносим последовательный алгоритм на GPU? 3. Допустим, у вас есть исключительный доступ к трем новым сверхсекретным GPU, которые одинаковы, за исключением одного – числа ядер. В первом – 131 072 ядра, во втором – 262 144, и в третьем – 524 288 ядер. Если вы распараллелите и перенесете на эти GPU расчет множества Мандельброта из нашего примера (который строит изображение 512×512), будет ли разница во времени выполнения между первым и вторым GPU? А как насчет второго и третьего? 4. Подумайте о каких-либо задачах или частях кода как распараллеливаемых в связи с законом Амдала. 5. Почему вместо функции time лучше всего использовать профилировщик?
Глава
2
Настройка окружения для программирования GPU Давайте теперь рассмотрим, как настроить подходящее окружение для программирования GPU под Windows и Linux. В обоих случаях необходимо предпринять несколько шагов. Мы их проделаем один за другим, обращая внимание на разницу между Windows и Linux. Можете, конечно, пропустить или проигнорировать любую часть либо комментарии, которые не соответствуют вашему выбору операционной системы. Советую обратить особое внимание на то, что мы будем рассматривать только две платформы для 64-битовых PC на основе процессоров Intel/AMD – Ubuntu LTS (longterm support) и Windows 10. Имейте в виду, что любая Ubuntu LTS система (из таких, как Xubuntu, Kubuntu или Linux Mint) так же хорошо подходит, как и Unity/GNOME Ubuntu. Предлагаю использовать Python 2.7 вместо Python 3.x. У Python 2.7 имеется стабильная поддержка всех библиотек, которые мы будем использовать в этой книге. Потому каждый пример проверен именно на языке программирования Python 2.7 на Windows и Linux. Пользователи Python 3.x также могут использовать данную книгу, но должны при этом иметь в виду, что между этими двумя языками имеются большие отличия. Некоторые из примеров были проверены на Python 3.7, но требуют стандартных изменений, таких как добавление круглых скобок в вызове функции print. Автор Packt доктор Себастиян Рашка выложил список наиболее важных отличий между Python 2.7 и 3.x по адресу https://sebastianraschka.com/Articles/2014_python_2_3_key_ diff.html.
Лучше всего для этих целей подойдет дистрибутив Anaconda Python 2.7 и для Windows, и для Linux, поскольку он может легко устанавливаться без администраторских прав, содержит все необходимые модули для анализа и визуализации данных, требуемые в книге, и использует быстрые оптимизированные пакеты NumPy/SciPy, применяющие библиотеку Math Kernel Library (MKL) от Intel. (Стандартная установка для Linux /usr/bin/python также может быть ис-
Убедитесь, что у вас есть требуемое оборудование 29 пользована для этой книги, но тогда читателю придется вручную устанавливать такие пакеты, как NumPy и Matplotlib.) Anaconda Python (версий 2.7 и 3.х) может быть скачана для всех платформ по адресу https://www.anaconda.com/download/.
Пользователи, работающие с другими поддерживаемыми платформами (например, macOS, Windows 7/8, Windows Server 2016, Red Hat/Fedora, OpenSUSE и CENTOS), должны обратиться к официальной документации по NVIDIA CUDA (https://docs.nvidia.com/cuda/) за дополнительными деталями. Кроме того, имеются и иные варианты по выбору необходимой аппаратуры: читателям, заинтересованным во встраиваемых системах, таких как Raspberry Pi, рекомендую начать с использования платы, основанной на ARM-процессорах для разработки NVIDIA Jetson, в то время как тем, кто находит себя в облачных вычислениях или веб-программировании, советую рассмотреть удаленную работу через Azure или AWS. В подобных случаях следует хорошенько изучить официальную документацию для настройки их драйверов, компилятора и CUDA Toolkit, но нужно учитывать, что отдельные шаги из данной главы не всегда могут подойти для всех случаев. В результате проделанных действий вы: сможете убедиться, что имеете необходимое оборудование; установите NVDIA GPU драйверы; установите соответствующее окружение для программирования на С/С++; установите NVIDIA CUDA Toolkit; установите окружение для Python для программирования GPU.
технические требования Для этой главы предлагается использовать установленную Anaconda Python 2.7: https://www.anaconda.com/download/. Код доступен на GitHub: https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA. За дополнительной информацией о требованиях обратитесь к введению в книгу и файлу README по адресу: https://github.com/PacktPublishing/Hands-On-GPU-Programmingwith-Python-and-CUDA.
убеДитесь, что у вас есть требуемое оборуДование Мы рекомендуем для практического применения как минимум следующее оборудование: 64-битовый компьютер на базе процессора от Intel/AMD; 4 Гб памяти (RAM); NVIDIA GeForce 1050 GTX (или старше).
30
Настройка окружения для программирования GPU
Эта конфигурация гарантирует, что вы сможете комфортно изучать программирование GPU, запускать все примеры из книги, а также другие интересные приложения, использующие GPU, такие как TensorFlow от Google (библиотека по машинному обучению) или Vulkan SDK (современная графическая библиотека). Обратите внимание, что перед началом работы с данной книгой у вас должен быть установлен новый NVIDIA GPU! CUDA Toolkit рассчитан только на NVIDIA GPU, поэтому он не будет взаимодействовать с Intel HD или Radeon GPU.
Как уже отмечалось выше, мы подразумеваем, что вы используете либо Windows 10, либо Ubuntu LTS (longterm service). Версии Ubuntu LTS обычно имеют номера версий вида 14.04, 16.04, 18.04 и т. д.
Ubuntu LTS является одной из наиболее распространенных версий Linux, что обеспечивает наибольшую совместимость программного обеспечения и библиотек. Имейте в виду, что существует множество вариаций Linux, основанных на Ubuntu, таких как Linux Mint, Xubuntu. Они также идеально подходят для осуществления ваших целей. (Я лично обнаружил, что Linux Mint неплохо работает на ноутбуках с установленными GPU.) Также стоит отметить, что мы считаем, что у вас имеется как минимум GTX 1050 (Pascal) GPU или ее эквивалент для любой новой архитектуры. Большинство примеров из книги, скорее всего, будут работать и на старых версиях GPU, но они были проверены автором на GTX 1050 (под Windows) и GTX 1070 (под Linux). Хотя они и не проверялись на старых версиях GPU, таких как Maxwell 2014 г., GTX 750, их должно быть достаточно для достижениях тех целей, что упомянуты в книге. Если вы располагаете настольным ПК, то перед тем как переходить к дальнейшим действиям, убедитесь, что при установке GPU следовали всем приведенным инструкциям.
Проверка вашего оборудования (Linux) Сейчас мы произведем несколько базовых проверок в Linux, чтобы полностью убедиться в том, что ваше оборудование идеально походит для осуществления необходимых манипуляций. Откройте терминал и перейдите в командную строку bash – в Ubuntu это можно сделать, просто нажав на сочетание клавиш Ctrl+Alt+T. Теперь давайте проверим процессор, набрав lscpu и нажав клавишу Enter. Высветится множество различной информации, однако вам необходимо взглянуть на первую строку, чтобы убедиться в том, что архитектура действительно x86_64:
Убедитесь, что у вас есть требуемое оборудование 31
Далее проверим объем памяти на нашем оборудовании, набрав в строке bash команду free –g, после чего повторно нажмем на клавишу Enter. В поле первой строки появится сообщение об общем объеме памяти в гигабайтах, а также во второй строке – об объеме своп-памяти:
Это явно достаточный объем памяти. Наконец, давайте с вами проверим GPU на совместимость с оборудованием. NVIDIA GPU взаимодействуют с нашим компьютером через шину PCI, поэтому мы можем использовать команду lspci для просмотра всего оборудования, использующего PCI. Для того чтобы выбрать необходимое, мы применим команду grep для выбора только NVIDIA GPU, введя в командной строке lscpi | grep -e "NVIDIA":
Это GTX 1070, которая является более мощной, чем требуемая нами GTX 1050.
Проверка вашего оборудования (Windows) Для начала откройте панель Windows. Это обычно можно сделать сочетанием клавиш Windows+R и последующим запуском Control Panel, как показано на следующем изображении:
32
Настройка окружения для программирования GPU
После этого запустите панель управления Windows. Щелкните по разделу System and Security и в открывшемся диалоговом окне выберите System. Перед вами высветится сообщение, извещающее об объеме памяти (RAM) и о наличии установленного 64-битового процессора:
Для проверки вашего GPU щелкните по кнопке Device Manager в левом верхнем углу диалогового окна. Запустится программа Windows Device Manager. Откройте приложение Display Adapters для проверки того, какие GPU установлены в вашей системе:
Установка драйверов для GPU 33
установка Драйверов Для GPU Если у вас уже установлены драйверы для GPU, то можете смело пропустить данный шаг. Кроме того, некоторые версии CUDA уже содержат в себе необходимые драйверы. Довольно часто CUDA бывает чересчур требовательной к версии определенного драйвера и может отказываться работать с другим, поэтому, возможно, вам придется немного поэкспериментировать, прежде чем отыщется работающий. Честно говоря, Windows отличается от Linux лучшей совместимостью драйверов и более дружественной их установкой. Однако к пользователям Windows этот поиск не относится, так как они могут использовать тот драйвер, который шел в комплекте к CUDA Toolkit. Его установкой мы займемся несколько позднее, а пока настоятельно рекомендуем пользователям Linux (особенно владельцам ноутбуков) по порядку выполнить все требования, содержащиеся в данном разделе.
Установка драйверов GPU (Linux) В Ubuntu драйвером по умолчанию для NVIDIA GPU является драйвер с открытым исходным кодом под названием Nouveau. К сожалению, он не поддерживает CUDA, поэтому нам нужно будет установить проприетарный драйвер от NVIDIA. Для этого придется добавить специальный репозиторий graphicsdrivers в наш менеджер пакетов, чтобы появилась возможность для скачивания проприетарных драйверов от NVIDIA для нашей системы. Для добавления репозитория необходимо выполнить следующую команду в bash:
34
Настройка окружения для программирования GPU
sudo add-apt-repository ppa:graphics-drivers/ppa
Поскольку мы используем sudo, то вам придется ввести ваш пароль. Теперь мы синхронизируем нашу систему с новым репозиторием, запустив следующую команду: sudo apt-get update
Сейчас мы готовы к установке драйвера. Войдите на рабочий стол Ubuntu и нажмите комбинацию клавиш Windows+R. После чего введите software and drivers:
Появится меню Software & Drivers. В нем откройте закладку Additional Drivers. Вы увидите набор различных стабильных проприетарных драйверов для своего GPU. Выберите самый новейший (в моем случае это nvidia-driver-396, что показано ниже).
После того как вы его выбрали, нажмите на кнопку Apply Changes. Появится диалоговое окно с требованием ввести пароль для sudo. Как только вы выполните данное требование, высветится сообщение, что драйвер установлен. Обратите внимание, что установка может занять некоторое время, и иногда может казаться, что ваш компьютер висит. Обычно на это уходит более часа, так что, пожалуйста, проявите терпение.
Установка окружения для программирования на С++ 35 Наконец, когда процесс будет завершен, перезагрузите ваш компьютер и вернитесь на рабочий стол Ubuntu. Нажмите комбинацию клавиш Windows+A, а затем введите команду nvidia-settings (или запустите эту программу из bash). Откроется интерфейс NVIDIA X Server Settings, где будет выдана запись, что вы используете подходящую версию драйвера:
Установка драйвера GPU (Windows) Повторюсь: в общем случае предлагается просто пропустить данный шаг и затем установить драйверы, которые идут в комплекте к CUDA Toolkit. Последние драйверы для Windows можно скачать непосредственно с сайта http://www.nvidia.com/Download/. Просто выберите подходящие драйверы Windows 10 для своего GPU из выпадающего меню. Обычно они являются выполнимыми exe-файлами. Далее установите драйвер, всего лишь дважды щелкнув мышью по соответствующему файлу в менеджере файлов.
установка окружения Для программирования на с++ После установки драйверов мы можем переходить к среде программирования на С/С++. Как Python, так и CUDA имеют свои требования к компиляторам и IDE, с которыми они могут объединяться, так что вам необходимо быть более аккуратными. Для пользователей Ubuntu Linux стандартные компиляторы и IDE из репозитория обычно подходят, в то время как пользователям Windows нужно проявить некоторую осмотрительность.
Настройка GCC, Eclipse IDE и графических зависимостей (Linux) Теперь проделаем такую операцию, как открытие терминала на рабочем столе Ubuntu (Ctrl+Alt+T). Для начала следует обновить репозиторий apt следующим образом: sudo apt-get update
36
Настройка окружения для программирования GPU
Теперь мы можем установить все программы, которые нам понадобятся для CUDA, одной строкой: sudo apt-get install build-essential binutils gdb eclipse-cdt
Здесь build-essential – это пакет, содержащий компиляторы gcc и g++, а также другие утилиты, такие как make; binutils включает в себя некоторые полезные программки, такие как линковщик LD, отладчик gdb. А Eclipse – это IDE, которую мы в дальнейшем с вами будем использовать. Следующим шагом будет установка нескольких дополнительных пакетов, которые позволяют нам запускать некоторые из графических (OpenGL) примеров, включенных в состав CUDA Toolkit: sudo apt-get install freeglut3 freeglut3-dev libxi-dev libxmu-dev
Теперь мы готовы для установки CUDA Toolkit.
Установка Visual Studio (Windows) На момент написания только одна версия Visual Studio полноценно интегрировалась и с Python, и с последним CUDA Toolkit – Visual Studio 2015, т. е. Visual Studio версии 14.0. Хотя и было бы возможным сделать подустановку ее под последней версией Visual Studio (например, 2017), мы все же предлагаем читателю непосредственно установить Visual Studio 2015 с поддержкой С/С++. Свободную версию Visual Studio Community 2015 вы можете скачать с сайта https:// visualstudio.microsoft.com/vs/olderdownloads/.
Здесь же мы хотим продемонстрировать частичную установку, включающую в себя лишь самые необходимые для CUDA компоненты.
Установка окружения для программирования на С++ 37 Запустите установщик и выберите тип Custom:
Затем нажмите на кнопку Next и из выпадающего списка Programming Languages выберите Visual C++ (вы также можете воспользоваться и другими языками или пакетами, если они вам нужны для каких-либо целей, но для программирования GPU нам понадобится только Visual C++):
38
Настройка окружения для программирования GPU
Данный процесс займет некоторое время, по завершении которого можно будет приступить к установке CUDA Toolkit.
Установка CUDA Toolkit Итак, мы приближаемся к первоначальной цели! Для ее осуществления скачайте CUDA Toolkit, перейдя на сайт https://developer.nvidia.com/cuda-downloads. Затем выберете подходящую операционную систему. Перед вами появится список, состоящий из нескольких опций. Как для Windows, так и для Linux доступна и сетевая, и локальная установка. Обычно я для Windows и Linux применяю локальную, поскольку предпочитаю сперва скачать весь пакет, так как если обнаружатся какие-либо сетевые проблемы, то они точно не произойдут во время установки CUDA Toolkit.
Установка CUDA Toolkit (Linux) Для пользователей Linux присутствует некоторая вариативность – использовать файл .deb или .run. Многим я предлагаю применять файл .deb, поскольку он автоматически загрузит любые отсутствующие пакеты, которые необходи-
Установка окружения Python для программирования GPU 39 мы для CUDA. Файл .run производит установку, не затрагивая Advanced Package Tool (APT) вашей системы. По сути, он просто копирует файлы в бинарный и библиотечный каталоги /usr системы. Если вы не хотите связываться с системой APT или репозиториями и обладаете хорошим пониманием Linux, то этот вариант подойдет больше всего. В любом случае тщательно следуйте инструкциям по установке с сайта, которые могут различаться в зависимости от той или иной версии. После того как пакет успешно загрузится, необходимо сконфигурировать системные переменные PATH и LD_LIBRARY_PATH таким образом, чтобы ваша система смогла найти бинарные выполнимые файлы и библиотеки, необходимые для CUDA. Я предлагаю сделать это, просто добавив необходимые данные в конце файла .bashrc в вашем домашнем каталоге. Откройте файл ~/.bashrc в любимом текстовом редакторе, например gedit, nano, emacs или vim, и в самом конце введите следующие строки: export PATH="/usr/local/cuda/bin:${PATH} export LD_LIBRARY_PATH="/usr/local/cuda/lib64:${LD_LIBRARY_PATH}"
Затем сохраните файл и возвратитесь в окно терминала. Теперь вы можете убедиться, что CUDA Toolkit установлена верно, открыв новый терминал и введя в нем команду nvcc –version. Завершите ввод нажатием клавиши Enter. Перед вами появится информация о версии компилятора для CUDA (nvcc – это компилятор CUDA C, подобный компилятору gcc.).
Установка CUDA Toolkit (Windows) В случае с Windows вы можете просто установить пакет двойным щелчком мыши по .exe-файлу и следовать дальнейшим инструкциям, высветившимся на экране. После того как установка будет завершена, перезапустите вашу систему. Для того чтобы убедиться, что CUDA установлена верно, мы запустим компилятор nvcc. В меню Start откройте папку Visual Studio 2015, а затем выберите VS215 x64 Native Tools Command Prompt. Появится окно терминала. Введите в нем команду nvcc –version и нажмите клавишу Enter. Это позволит узнать, какая версия компилятора используется в вашем случае.
установка окружения Python Для программирования GPU После того как компиляторы IDE и CUDA Toolkit были правильно установлены в системе, мы можем создать подходящее окружение для программирования GPU на Python. Здесь существует много вариантов, но мы рекомендуем использовать Anaconda Python Distribution. Anaconda Python – это содержащий в себе все необходимое и дружелюбный по отношению к пользователю дистрибутив, который устанавливается непосредственно в ваш домашний каталог и не требует администраторских прав при использовании или обновлении.
40
Настройка окружения для программирования GPU
Имейте в виду, что Anaconda Python выпускается в двух версиях – Python 2.7 и Python 3. Поскольку Python 3 на данный момент еще не так хорошо поддерживается некоторыми библиотеками, которыми мы собираемся руководствоваться в своей работе, то в данной книге будет использоваться Python 2.7. Вы можете установить Anaconda Python, просто зайдя на сайт https://www. anaconda.com/download и, выбрав свою операционную систему, скачав необходимую версию, основанную на Python 2.7. Следуйте инструкциям на сайте Anaconda по установке, которые достаточно прямолинейны. Теперь нам требуется настроить локальную версию Python для программирования GPU. Сейчас мы установим наиболее важный пакет для этой книги – PyCUDA Андреаса Клекнера.
Установка PyCUDA (Linux) Откройте командную строку в Linux. Убедитесь, что ваша переменная окружения PATH настроена правильным образом для использования локальной версии Python из установленной Anaconda, просто набрав команду which python и нажав клавишу Enter (Anaconda автоматически обновляет ваш файл .bashrc в ходе установки). В результате вы увидите, что бинарный файл Python находится в вашем локальном каталоге ~/anaconda2/bin, а не в /usr/bin. Если это не так, то откройте текстовый редактор и добавьте строку export PATH="/home/${USER}/ anaconda2/bin:${PATH}" к концу вашего файла ~/.bashrc. Затем сохраните его. Откройте новое окно терминала и проверьте еще раз. Есть несколько вариантов установки PyCUDA. Наиболее простым является загрузка последней версии из репозитория PyPI при помощи команды pip install pycuda. Вы также можете установить последнюю версию PyCUDA, следуя инструкциям на официальном сайте PyCUDA по адресу: https://mathema.tician. de/software/pycuda/. Пожалуйста, обратите внимание, что если вы хотите переустановить PyCUDA из другого источника, то сначала удалите текущую версию при помощи pip uninstall pycuda.
Создание скрипта для настройки окружения (Windows) Пользователям Windows нужно быть особенно внимательными при настройке среды переменных окружения для Visual Studio и Anaconda. В противном случае Python не сможет найти компилятор nvcc от NVIDIA или компилятор cl.exe от Microsoft. К счастью, имеются готовые скрипты, автоматически настраивающие эти переменные, но необходимо четко следить за тем, чтобы они выполнялись каждый раз, когда мы хотим перейти к программированию GPU. Поэтому мы создадим свой скрипт, который запустит соответствующую IDE или окружения для командной строки, вызвав друг за другом два упомянутых скрипта. (Этот скрипт также доступен на сайте https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA/blob/master/2/launch-python-cuda-environment.bat.)
Установка окружения Python для программирования GPU 41 Для начала запустите Windows Notepad и следуйте дальнейшим указаниям. Определите, где располагается файл vcvars.bat для Visual Studio 2015. Обычно это C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\vcvarsall.bat. Наберите в текстовом редакторе строки следующего содержания и затем нажмите клавишу Enter: call "C:\Program Files (x86)\Microsoft Visual Studio 14.0\VC\vcvarsall.bat" amd64
Далее нам нужно вызвать скрипт activate.bat для настройки переменных окружения Anaconda Python. Обычно он расположен в Anaconda2\Scripts\activate.bat. Нам следует также указать, где находятся библиотеки Anaconda, через аргумент данного скрипта. У меня вторая строка скрипта для запуска имеет следующий вид: сall "C:\Users\%username%\Anaconda2\Scripts\activate.bat" C:\Users\%username%\Anaconda2.
Наконец, последняя строка указанного скрипта запустит то окружение, которое необходимо, – IDE или командную строку, – где вы собираетесь заниматься программированием, унаследовав при этом все необходимые системные переменные и переменные окружения, заданные теми двумя скриптами, что мы вызвали. Если вы предпочитаете работать со стандартной командной строкой, то это будет cmd. Если же вам ближе PowerShell, тогда речь пойдет о powershell.exe. В некоторых случаях будет необходимо использовать командную строку, в частности для вызова pip и conda для обновления библиотек Python. Далее сохраните данный файл на вашем рабочем столе под именем launchpython-cudaenvironment.bat. Теперь вы сможете запустить окружение для программирования GPU двойным щелчком по указанному файлу. (Имейте в виду, что если вы хотите использовать Jupyter Notebook или Spyder Python IDE, то можете просто запустить их из командной строки при помощи команды jupyter-notebook или spyder или же создать скрипт, заменив cmd на команду вызова соответствующей IDE.)
Установка PyCUDA (Windows) Поскольку большинство библиотек для Python пишутся пользователями Linux и для пользователей Linux, то мы предлагаем установить уже собранный бинарник PyCUDA с сайта Кристофера Голка https://www.lfd.uci.edu/~gohlke/ pythonlibs/#pycuda. Скачайте файл вида pycuda‑2017.1.1+cuda(VERSION)‑cp27‑cp27m -win_amd64.whl, где VERSION – это ваша версия CUDA. Теперь вы можете установить PyCUDA, просто набрав следующую строку и заменив pycuda.whl на полное имя и путь скачанного вами файла: pip install pycuda.whl
(Вместо этого вы можете попробовать установить PyCUDA из репозитория PyPI при помощи команды pip install pycuda или следуя указаниям с сайта PyCUDA.)
42
Настройка окружения для программирования GPU
Проверка PyCUDA Наконец, мы дошли до того момента, когда можем увидеть, как работает наше окружение для программирования GPU. Для этого запустим небольшую программу из следующей главы, которая опросит ваш GPU и соберет информацию о модели, объеме памяти, числе ядер, архитектуре и т. п. Возьмите файл (deviceQuery.py) из каталога 3 в репозитории, который также доступен по адресу: https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-andCUDA/blob/master/3/deviceQuery.py. Если вы используете Windows, то настройте окружение для программирования GPU, запустив соответствующий .bat-файл на своем рабочем столе, который мы создали в предыдущем разделе. Если же вы работаете в Linux, то откройте окно терминала bash. Наберите следующую строку и нажмите клавишу Enter: python deviceQuery.py
На экране высветится огромное количество строк, первые из которых покажут, что ваш GPU был обнаружен PyCUDA. На следующей строке появится запись о модели GPU:
Поздравляю, теперь вы готовы отправиться в мир программирования GPU!
резюме Настройка вашего окружения Python для программирования GPU может оказаться довольно тонким процессом. Для читателей книги, использующих Windows или Linux, предлагается применять Anaconda Python 2.7. Во-первых, мы должны убедиться, что у нас имеется подходящее оборудование для программирования GPU. В общем случае это 64-разрядный компьютер с Windows или Linux и с 4 Гб оперативной памяти и любым базовым GPU от NVIDIA, начиная с 2016 г. Пользователи Windows должны быть аккуратными в выборе версии Visual Studio, которая работает и с CUDA Toolkit, и c Anaconda (например, VS 2015), в то время как пользователям Linux предписывается некая деликатность в установке драйверов GPU и задании переменных окружения в файле .bashrc. Далее, пользователи Windows должны создать специальный скрипт для настройки программирования GPU и начать использовать заранее собранный wheel-файл для установки библиотеки PyCUDA. Теперь, после того как наше окружение было успешно настроено, в следующей главе мы разберем основы программирования GPU. Также увидим, как
Вопросы 43 читать и писать данные в и из памяти GPU и как создавать довольно простые поэлементные функции на CUDA C.
вопросы 1. Можем ли мы запустить CUDA на GPU, встроенном в Intel CPU? А что насчет AMD Radeon GPU? 2. Использует ли данная книга для примеров Python 2.7 или Python 3.7? 3. Какую программу мы должны использовать в Windows, для того чтобы увидеть, что за модель GPU у нас установлена? 4. Какую утилиту командной строки в Linux мы используем, чтобы увидеть, что за модель GPU у нас установлена? 5. Какую команду мы используем в Linux, для того чтобы узнать, сколько оперативной памяти находится в нашей системе? 6. Если мы не хотим задействовать репозиторий APT нашей системы, то для установки CUDA мы должны использовать установщик в виде файла.deb или .run?
Глава
3 Начало работы с CUDA
В предыдущей главе мы с вами настроили окружение для программирования GPU. Теперь, когда драйвер и компиляторы готовы к работе, приступим непосредственно к программированию! Сперва определимся, как начать использовать PyCUDA для выполнения некоторых базовых и основных действий. Для этого напишем небольшую программу на Python, которая сообщит некоторые характеристики нашего GPU, такие как число ядер, архитектура и память. Далее мы потратим немного времени, для того чтобы узнать, как скопировать память между Python и GPU при помощи класса gpuarray PyCUDA, а также как использовать этот класс для выполнения базовых расчетов. Оставшаяся часть главы уйдет на то, чтобы написать некоторые базовые функции (их мы в дальнейшем планируем называть ядрами CUDA), которые можно будет непосредственно запускать на GPU. Результатами изучения данной главы станут: определение характеристик GPU, таких как объем памяти или число ядер, при помощи PyCUDA; понимание разницы между памятью хоста (host, CPU) и устройства (device, GPU) и тем, как использовать класс gpuarray PyCUDA для копирования данных между хостом и устройством; умение выполнять базовые вычисления при помощи класса gpuarray; умение осуществлять базовые поэлементные операции благодаря функции PyCUDA ElementwiseKernel; понимание операций reduce/scan из функционального программирования и того, как получить базовое ядро CUDA для их совершения.
технические требования Для этой главы необходим ПК с Linux или Windows 10 и современным NVIDIA GPU (2016 г. или новее) с установленными драйверами и CUDA Toolkit. Также требуется подходящая инсталляция Python 2.7 (такая как Anaconda Python 2.7) с модулем PyCUDA. Код данной главы доступен по адресу: https://github.com/PacktPublishing/ Hands-On-GPU-Programming-with-Python-and-CUDA.
Опрос вашего GPU 45 За дополнительной информацией о требованиях обратитесь к разделу «Предисловие»; о требованиях к аппаратуре и программному обеспечению прочтите файл README https://github.com/PacktPublishing/Hands-On-GPUProgramming-with-Python-and-CUDA.
опрос вашего GPU Прежде чем мы начнем писать программы для GPU, нам необходимо узнать его технические возможности и ограничения. Мы можем использовать так называемый запрос параметров GPU (GPU query). Это простейшая операция, которая сообщит нам некоторые технические подробности нашего GPU, такие как доступный объем памяти и число ядер. NVIDIA предлагает готовый пример в виде утилиты командной строки, написанной на CUDA C, с названием deviceQuery, содержащейся в каталоге samples (и для Windows, и для Linux), которую можно использовать для выполнения данной операции. Давайте взглянем на ее вывод для ноутбука автора с Windows 10 (Microsoft Surface Book 2 с GTX 1050 GPU):
46
Начало работы с CUDA
А теперь рассмотрим некоторые наиболее важные подробности полученной здесь информации. Во-первых, мы с вами видим, что установлен только один GPU, Device-0. Так как предполагается, что на компьютере чаще всего установлено сразу несколько GPU, которые при этом успешно используются, CUDA назначает каждому устройству GPU свой индивидуальный номер. Бывают случаи, когда без него просто не обойтись, потому следует этот номер запомнить. Мы также можем видеть тип конкретного устройства (GTX 1050) и версию CUDA, которая задействована. Есть еще два значения, которые для нас представляют особую важность: общее число ядер (здесь их 640) и общий объем глобальной памяти на устройстве (в данном случае 2048 Мб, или 2 Гб). Хотя в результатах deviceQuery вы можете встретить много различных значений, число ядер и объем памяти обычно самые главные, на которые стоит обратить свое внимание, поскольку они дают первичное представление о возможностях вашего нового устройства.
Опрос вашего GPU при помощи PyCUDA Итак, мы отправляемся в наше путешествие в мир программирования GPU! Для начала напишем собственную версию deviceQuery на Python. Здесь мы сфокусируемся в первую очередь на объеме доступной памяти на устройстве, вычислительной способности (compute capability), числе мультипроцессоров и общем числе ядер CUDA. Мы приступим к инициализации CUDA следующим образом: import pycuda.driver as drv drv.init() Обратите внимание, что мы всегда будем инициализировать PyCUDA при помощи вызова pycuda.driver.init() или импортированием подмодуля autoinit PyCUDA – import pycuda.autoinit!
Теперь мы можем проверить, сколько устройств GPU у нас имеется на компьютере, при помощи прописывания следующей строки: print 'Обнаружено {} CUDA–совместимых устройств'.format(drv.Device.count())
Давайте введем данную строку в IPython и посмотрим, что получится:
Великолепно! Таким образом, я убедился, что мой ноутбук действительно содержит внутри себя один GPU. Предлагаю вам получить более интересную информацию об этом GPU (и любом другом), добавив всего лишь несколько строк кода для обхода всех
Опрос вашего GPU 47 устройств в системе. Мы можем это сделать, просто обратившись к pycuda.driver.Device по индексу. Имя устройства (например, GeForce GTX 1050) задается функцией name. Также можно узнать вычислительную способность (compute capability) при помощи функции compute_capability, а общий объем памяти устройства – при помощи функции total_memory. Вычислительная способность может рассматриваться как номер версии для архитектуры NVIDIA GPU; она дает важную информацию об устройстве, которую мы, как вскоре и убедимся, не способны получить никаким другим образом.
Давайте введем следующий код: for i in range(drv.Device.count()): gpu_device = drv.Device(i) print 'Устройство {}: {}'.format( i, gpu_device.name() ) compute_capability = float( '%d.%d' % gpu_device.compute_capability() ) print '\t Compute Capability: {}'.format(compute_capability) print '\t Общая память: {} megabytes'.format(gpu_device.total_memory()//(1024**2))
Теперь можно перейти к ознакомлению с некоторыми из оставшихся атрибутов GPU, которые PyCUDA предоставляет нам в виде словаря языка Python. Мы воспользуемся следующими строками кода для перевода данного помощника в словарь, индексируемый строками, обозначающими атрибуты: device_attributes_tuples = gpu_device.get_attributes().iteritems() device_attributes = {} for k, v in device_attributes_tuples: device_attributes[str(k)] = v
После проделанных операций можно определить число мультипроцессоров в нашем устройстве при помощи следующего кода: num_mp = device_attributes['MULTIPROCESSOR_COUNT']
GPU объединяет отдельные ядра в большие группы, называемые потоковыми мультипроцессорами (Streaming Multiprocessor, SM). GPU содержит несколько мультипроцессоров, каждый из которых включает определенное количество ядер CUDA в зависимости от вычислительной способности устройства. Точнее: число ядер в мультипроцессоре не обозначается непосредственно в самом GPU – оно задается его вычислительной способностью. Мы обратимся к некоторым техническим документам от NVIDIA для определения числа ядер в мультипроцессоре (см. http://docs.nvidia.com/cuda/cuda-c-programming-guide/ index.html#compute-capabilities) и затем создадим таблицу, которая будет сообщать их точное число. Мы сделаем это, используя переменную compute_capability для получения числа ядер: cuda_cores_per_mp = { 5.0 : 128, 5.1 : 128, 5.2 : 128, 6.0 : 64, 6.1 : 128, 6.2 : 128}[compute_capability]
48
Начало работы с CUDA
Теперь мы можем определить общее число ядер в нашем устройстве, просто перемножив эти два числа: print '\t ({}) Мультипроцессоров, ({}) Ядер CUDA / мультипроцессор: {} Ядер CUDA'.format(num_mp, cuda_cores_per_mp, num_mp*cuda_cores_per_mp)
Осталось только завершить нашу программу, просто проверив все оставшиеся ключи в нашем словаре, и напечатать соответствующие значения: device_attributes.pop('MULTIPROCESSOR_COUNT') for k in device_attributes.keys(): print '\t {}: {}'.format(k, device_attributes[k])
Мы наконец-то написали нашу первую программу для GPU! (Она также доступна по адресу: https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA/blob/master/3/deviceQuery.py.) Попробуем запустить ее, как показано ниже:
Итак, мы можем гордиться тем, что создали программу для опроса параметров GPU! Предлагаю от простых наблюдений за работой нашего GPU перейти непосредственно к его использованию.
Использование класса gpuarray модуля PyCUDA 49
использование класса gpuarray моДуля PyCUDA Как класс array из библиотеки NumPy является краеугольным камнем для необходимых вычислений, так же класс gpuarray из библиотеки PyCUDA выполняет роль программирования GPU на Python. У него есть все необходимые черты, которые вы знаете и любите в NumPy, – структура многомерного вектора/ матрицы/тензора, возможность среза и перегруженные операторы для поэлементных вычислений (например, +, -, *, / и **). Класс gpuarray является незаменимым инструментом для любого начинающего программиста GPU. Поэтому, прежде чем мы двинемся дальше, необходимо рассмотреть данный класс для лучшего его понимания.
Перенос данных в и из GPU при помощи gpuarray Как мы заметили в ходе написания нашей предыдущей программы deviceQuery на Python, у GPU есть своя собственная память, отличная от основной (host) памяти компьютера, чаще всего называемая памятью устройства. (Иногда ее принято считать глобальной памятью устройства для отличия от дополнительной памяти в виде кешей, разделяемой памяти, регистров, которые тоже находятся внутри GPU.) Обычно мы рассматриваем (глобальную) память устройства так же, как рассматриваем динамически выделяемую память с кучи в С (при помощи функций malloc и free) или С++ (выделяемую через операторы new и delete). В CUDA C этот процесс осложняется дополнительной задачей по переносу данных между CPU и GPU (при помощи таких команд, как cudaMemcpyDeviceToHost и cudaMemcpyHostToDevice), отслеживая при этом многочисленные указатели в памяти как CPU, так и GPU, и выполнением выделения (cudaMalloc) и освобождения (cudaFree) памяти GPU. К счастью, PyCUDA накрывает весь функционал выделения, освобождения и копирования данных через класс gpuarray. Как уже отмечалось, этот класс ведет себя подобно массивам в NumPy, используя вид вектора/матрицы/тензора для хранения данных. Объекты класса gpuarray даже выполняют автоматическое освобождение памяти по истечении времени их жизни, поэтому вам не нужно беспокоиться об освобождении памяти GPU, содержащейся в объекте gpuarray, после того как мы завершили работу с ним. Как же мы будем копировать данные с хоста (CPU) на GPU? Во-первых, нам нужно, чтобы данные на стороне хоста содержались в каком-то виде массива NumPy (давайте назовем их host_data). После чего мы используем вызов gpuarray.to_gpu(host_data) для копирования данных на GPU и создания нового массива на GPU. Теперь мы выполним простое вычисление на GPU (поэлементное умножение на константу на GPU) и затем извлечем данные с процессора при помощи вызова gpuarray.get. Для этого запустим IPython и посмотрим, как это работает (обратите внимание, что на этот раз мы инициализируем PyCUDA через import pycuda.autoinit):
50
Начало работы с CUDA
Важным моментом, на который нужно обратить особое внимание, является то, что тип массива на стороне хоста явно задан в тип NumPy float32 через опцию dtype при создании нашего массива NumPy. Это соответствует типу float в С/С++. Вообще говоря, лучше наглядно задавать тип в NumPy при переносе данных на GPU. На это есть две причины: во-первых, поскольку мы используем GPU для увеличения скорости, то не хотим дополнительных затрат времени и памяти от использования ненужного типа, и во-вторых, так как нам совсем уже скоро понадобится писать части кода на CUDA C, необходимо быть предельно точными в задании типов данных, иначе наш код не станет правильно работать, ибо С является языком со статической типизацией. Не забывайте явно задавать тип данных для массивов NumPy, которые будут потом копироваться на GPU. Это можно сделать при помощи опции dtype в конструкторе класса numpy.array.
Использование основных поэлементных операций через методы gpuarray В последнем примере мы увидели, что можно использовать оператор (*) для умножения каждого элемента в gpuarray на скалярное значение (это было 2). Обратите внимание, что поэлементные операции естественным образом распараллеливаются, и поэтому, когда мы применяем данную операцию к объекту класса gpuarray, то PyCUDA может вынести каждое такое умножение в отдельную нить, вместо того чтобы выполнять их последовательно, одно за другим (на самом деле некоторые версии NumPy могут использовать команды SSE, имеющиеся в современных процессорах х86, поэтому в некоторых случаях быстродействие может оказаться сравнимым с GPU). Для ясности: эти поэлементные операции выполняются параллельно на GPU, поскольку вычисление одного элемента не зависит от вычисления любого другого.
Использование класса gpuarray модуля PyCUDA 51 Для того чтобы почувствовать, как подобные операторы работают, я предлагаю читателю запустить IPython, создать несколько объектов gpuarray на GPU и поиграться с ними. Вы заметите, что они выполняют свои функции так же, как и для массивов в NumPy. Ниже приводятся следующие примеры:
Итак, мы видим, что объекты gpuarray ведут себя вполне предсказуемо и так же, как и массивы в NumPy. (Обратите внимание, что нам приходится копировать результаты из памяти GPU при помощи метода get!) Давайте сравним время выполнения на CPU и GPU, для того чтобы увидеть, есть ли, и какой, выигрыш от переноса этих операций на GPU.
52
Начало работы с CUDA
Тест на скорость Для начала напишем небольшую программку (time_calc0.py), которая выполнит сравнение скорости между скалярным умножением на CPU и такой же операцией на GPU. Мы будем использовать функцию allclose из NumPy для сравнения выходных значений. Далее создадим массив из 50 млн случайных 32-битовых значений с плавающей точкой (это будет примерно соответствовать 48 Мб данных, что легко выполнимо на современных компьютерах и GPU, обладающих несколькими гигабайтами оперативной памяти) и затем замерим, сколько времени занимает умножение массива на 2 на каждом из устройств. В конце мы сравним выходные значения, для того чтобы убедиться, что они совпадают. Вот как это делается: import numpy as np import pycuda.autoinit from pycuda import gpuarray from time import time host_data = np.float32( np.random.random(50000000) ) t1 = time() host_data_2x = host_data * np.float32(2) t2 = time() print 'total time to compute on CPU: %f' % (t2 ‑ t1) device_data = gpuarray.to_gpu(host_data) t1 = time() device_data_2x = device_data * np.float32( 2 ) t2 = time() from_device = device_data_2x.get() print 'Общее время для расчета на GPU: %f' % (t2 ‑ t1) print 'Совпадает ли результат на хосте и GPU? : {}'.format(np.allclose(from_device, host_data_2x) )
(Вы можете скачать файл time_calc0.py из репозитория, указанного ранее.) Теперь давайте запустим IPython и выполним эту программу несколько раз, для того чтобы получить общее представление о том, сколько времени это займет и каковы будут колебания времени выполнения. (Здесь приводятся результаты для компьютера 2017 г. Microsoft Surface Book 2 с процессором Kaby Lake i7 и GPU GTX 1050.)
Использование класса gpuarray модуля PyCUDA 53
Первое, что бросается в глаза, – вычисления на CPU занимают примерно одно и то же время при каждом запуске (примерно 0,08 с). Также мы видим, что вычисления на GPU происходят медленнее, чем на CPU при первом запуске (1,09 с), и становятся заметно быстрее при последующих (в диапазоне от 7 до 9 мс). Если вы выйдете из IPython и снова запустите программу, то произойдет то же самое. В чем причина? Давайте сначала проведем небольшое исследование при помощи встроенного профайлера prun из IPython. Он работает аналогично модулю cProfiler. Для начала загрузим нашу программу как текст внутрь IPython, как показано ниже, для того чтобы после у нас появилась возможность для загрузки профайлера через выполнение команды exec: with open('time_calc0.py','r') as f: time_calc_code = f.read()
Далее мы наберем %prun -s cumulative exec(time_calc_code) в консоли IPython (вместе со стартовым %) и посмотрим, какие операции занимают больше всего времени:
54
Начало работы с CUDA
Как видим, высветилось несколько подозрительных вызовов к модулю compiler.py. Эти вызовы занимают в сумме примерно 1 с, что немного меньше, чем требуется для выполнения вычислений на GPU. Осуществим перезапуск и посмотрим, не появилась ли какая-то разница:
Использование ElementWiseKernel из PyCUDA 55 Обратите внимание, что в этот раз нет вызовов compiler.py. Почему? Библиотека PyCUDA так устроена, что часто код для GPU переводится и линкуется при помощи компилятора nvcc от NVIDIA при первом запуске в сессии Python. Далее результаты компиляции и линковки кешируются, так что при последующих вызовах кода уже нет необходимости в проведении повторных процессов. Такое может происходить даже при столь простой операции, как умножение! (Позже мы увидим, что это можно улучшить при помощи предкомпилированного кода, (см. главу 10 «Работа с компилированным кодом для GPU») или за счет использования библиотек линейной алгебры от NVIDIA при помощи модуля SciKit-CUDA (см. главу 7 «Использование библиотек CUDA вместе со ScikitCUDA»). В PyCUDA часто код для GPU компилируется во время выполнения при помощи компилятора nvcc от NVIDIA и затем вызывается из PyCUDA. При первом выполнении программы внутри сессии Python это может обычно приводить к непредвиденным задержкам.
использование ElEmEntWisEKErnEl из PyCUDA Для выполнения поэлементных операций Сейчас мы увидим, как запрограммировать поэлементные операции прямо на нашем GPU при помощи функции ElementWiseKernel из PyCUDA. Это то место, где наши знания С/С++ становятся полезными – нам придется написать немного кода на CUDA C, который будет откомпилирован при помощи nvcc от NVIDIA и затем запущен во время выполнения при помощи PyCUDA. Мы довольно часто будем использовать термин ядро в данной книге, подразумевая под этим функцию, которая будет запускаться непосредственно на GPU при помощи CUDA. Также мы собираемся применять несколько функций из PyCUDA, которые создают шаблоны для различных типов ядер, облегчая наш переход в мир программирования GPU. Итак, ныряем. А начнем с явного переписывания кода для умножения каждого элемента объекта gpuarray на 2 на CUDA C. Для генерации нашего кода необходимо выполнить функцию ElrementWiseKernel из PyCUDA. Начнем с набора следующего кода прямо в консоли IPython. (Можно просто скачать этот текст из репозитория на GitHub; соответствующее имя файла – simple_element_kernel_example0.py): import numpy as np import pycuda.autoinit from pycuda import gpuarray from time import time from pycuda.elementwise import ElementwiseKernel host_data = np.float32( np.random.random(50000000) ) gpu_2x_ker = ElementwiseKernel( "float *in, float *out", "out[i] = 2*in[i];", "gpu_2x_ker")
56
Начало работы с CUDA
Давайте теперь посмотрим на полученный код. Конечно, он содержит несколько строк кода на CUDA C. Для начала мы зададим входные и выходные переменные в первой строке ("float *in, float *out"), которые будут иметь вид указателей языка С на выделенную на GPU память. Во второй строке мы определим нашу поэлементную операцию как "out[i] = 2*in[i];", т. е. умножим каждый элемент из in на 2 и запишем результат в out по соответствующему индексу. Обратите внимание, что PyCUDA автоматически задает индекс i для нас. Когда мы его используем по назначению, то ElementWiseKernel автоматически распараллелит наши вычисления по i среди ядер GPU. Наконец, мы дадим нашему фрагменту кода его внутреннее имя ядра в CUDA C ("gpu_2x_ker"). Так как оно относится к пространству имен CUDA C, а не Python, то мы можем использовать это же имя и в Python (что довольно удобно). Приступим к выполнению сравнения скорости: def speedcomparison(): t1 = time() host_data_2x = host_data * np.float32(2) t2 = time() print 'total time to compute on CPU: %f' % (t2 ‑ t1) device_data = gpuarray.to_gpu(host_data) # выделяем память под результат device_data_2x = gpuarray.empty_like(device_data) t1 = time() gpu_2x_ker(device_data, device_data_2x) t2 = time() from_device = device_data_2x.get() print 'Общее время расчета на GPU: %f' % (t2 ‑ t1) print 'Совпадает ли результат на хосте с GPU? : {}'.format(np.allclose(from_device, host_data_2x) ) if __name__ == '__main__': speedcomparison()
После чего запустим программу:
Вау! Это не выглядит хорошо. Для проверки запустим функцию speedcomparison() несколько раз из IPython:
Использование ElementWiseKernel из PyCUDA 57
Как вы можете видеть, скорость кардинально увеличилась после первого раза, когда мы использовали нашу функцию. Как и в предыдущем примере, это произошло потому, что PyCUDA компилирует наш код на CUDA C при первом вызове при помощи nvcc. После того как код был откомпилирован, он кешируется и может применяться вновь и вновь во время текущей сессии Python. Перед тем как переходить дальше, предлагаю рассмотреть еще одну важную деталь. Наше маленькое ядро, которое мы определили, работает над указателями на C float. Это значит, что нам нужно выделить некоторый объем неинициализированной памяти на GPU, на которую будет указывать переменная out. Давайте еще раз взглянем на фрагмент кода из функции speedcomparison(): device_data = gpuarray.to_gpu(host_data) # выделяем память под результат device_data_2x = gpuarray.empty_like(device_data)
Как и раньше, мы пошлем массив NumPy (host_data) на GPU через метод gpuarray.to_gpu, который автоматически выделяет память на GPU и копирует данные из памяти CPU. Мы используем это в качестве in в нашем ядре. На следующей строке мы выделим неинициализированную память на GPU при помощи метода gpuarray.empty_like. Она выступает в роли функции malloc в С, выделяя массив того же размера и типа, что и device_data, но ничего при этом не копируя. Мы используем это в качестве out в нашем ядре. Затем обратимся к следующей строке в speedcomparison(), для того чтобы посмотреть, как запустить наше ядро на GPU (игнорируя строки, используемые для замера времени): gpu_2x_ker(device_data, device_data_2x)
58
Начало работы с CUDA
Переменные соответствуют первой строке, заданной для ElementWiseKernel (в нашем случае это "float *in, float *out").
Возвращаемся к множеству Мандельброта Давайте снова посмотрим на задачу построения множества Мандельброта из главы 1 «Почему программирование GPU?». Исходный код доступен в каталоге 1 в репозитории под именем mandelbrot0.py, и вам лучше взглянуть на него, прежде чем мы продолжим. Данная программа состоит из двух основных частей. Первая – это построение множества Мандельброта; вторая – сохранение его в PNG-файл. В главе 1 мы поняли, что можем распараллелить только построение множества Мандельброта, и учитывая, что данный процесс занимает основную часть времени, то это хороший кандидат для переноса на GPU. Попробуем разобраться, как это сделать. (Мы воздержимся от повторения определения множества Мандельброта. Если желаете, то можете самостоятельно перечитать соответствующий раздел из главы 1.) Сперва посмотрим на новую функцию на Python, основанную на simple_mandelbrot, из исходной программы. Мы назовем ее gpu_mandelbrot. Она берет на вход те же параметры, что и раньше: def gpu_mandelbrot(width, height, real_low, real_high, imag_low, imag_high, max_iters, upper_bound):
А далее будут появляться некоторые отличия. Мы начнем с построения сетки, состоящей из каждой точки на комплексной плоскости, которую будем анализировать. Для этого мы используем несколько приемов работы с типом matrix из NumPy, для того чтобы сгенерировать сетку, а затем перевести ее из типа matrix в двумерный array (так как PyCUDA может работать только с типом array, а не с matrix). Обратите внимание, как мы аккуратно задаем типы: real_vals = np.matrix(np.linspace(real_low, real_high, width), dtype=np.complex64) imag_vals = np.matrix(np.linspace( imag_high, imag_low, height), dtype=np.complex64) * 1j mandelbrot_lattice = np.array(real_vals + imag_vals.transpose(), dtype=np.complex64)
Итак, имеется двумерный массив из комплексных чисел, представляющий собой сетку, на которой мы будем создавать множество Мандельброта, и, как мы скоро увидим, это очень легко может быть сделано на GPU. Теперь скопируем нашу сетку на GPU и выделим массив, который будет представлять наше множество Мандельброта: # копируем сетку комплексных значений на GPU mandelbrot_lattice_gpu = gpuarray.to_gpu(mandelbrot_lattice) # выделяем пустой массив на GPU mandelbrot_graph_gpu = gpuarray.empty(shape=mandelbrot_lattice.shape,dtype=np.float32)
Использование ElementWiseKernel из PyCUDA 59 Метод gpuarray.to_array может работать только с типом array из NumPy, поэтому нам было нужно преобразовать тип перед передачей на GPU. Далее нам требуется выделить память на GPU при помощи gpuarray.empty, задав размер и форму массива, а также его тип. Вы можете рассматривать этот вызов как аналог malloc в C. Помните, что нам не нужно освобождать эту память, поскольку деструктор класса gpuarray автоматически выполнит эту задачу при выходе из области видимости. Когда вы выделяете память на GPU при помощи gpuarray.empty или gpuarray.empty_like, нет необходимости освобождать эту память, поскольку за вас все сделает деструктор класса gpuarray.
Мы пока еще не написали ядро для создания множества Мандельброта, но для начала давайте проделаем ту же операцию с оставшейся частью нашей функции: mandel_ker( mandelbrot_lattice_gpu, mandelbrot_graph_gpu, np.int32(max_iters), np.float32(upper_bound)) mandelbrot_graph = mandelbrot_graph_gpu.get() return mandelbrot_graph
Мы поняли, как должно работать наше ядро. Первым входным значением будет сетка комплексных точек (используя тип complex64 из NumPy), которую мы создали, вторым – указатель на двумерный массив чисел с плавающей точкой (типа float32), который будет обозначать, какие элементы являются членами множества Мандельброта, третьим – целое число, задающее максимальное количество итераций для каждой точки, а последним входным значением – верхняя граница, используемая для проверки принадлежности множеству Мандельброта. Обратите внимание, что мы очень аккуратны в типах данных во всем, что передается на GPU! Следующая строка берет построенное множество Мандельброта и копирует его из GPU в память CPU, после чего данное значение возвращается. (Обратите внимание, что входные и выходные значения gpu_mandelbrot совпадают с теми, которые необходимы для simple_mandlebrot.) Посмотрим, как можно определить наше ядро. Во-первых, давайте добавим соответствующие операторы include в наш заголовок: import pycuda.autoinit from pycuda import gpuarray from pycuda.elementwise import ElementwiseKernel
Теперь мы готовы написать наше ядро! mandel_ker = ElementwiseKernel( "pycuda::complex *lattice, float *mandelbrot_graph, int max_iters, float upper_bound", """ mandelbrot_graph[i] = 1;
60
Начало работы с CUDA
pycuda::complex c = lattice[i]; pycuda::complex z(0,0); for (int j = 0; j < max_iters; j++) { z = z*z + c; if(abs(z) > upper_bound) { mandelbrot_graph[i] = 0; break; } } """, "mandel_ker")
Сначала мы зададим входные значения при помощи первой строки, переданной в ElementWiseKernel. Нам нужно понимать, что когда мы работаем с CUDA C, определенные типы С соответствуют таким же в NumPy. Верно и то, что когда мы передаем массивы в ядро CUDA, то они видны как указатели в С. Тип int в CUDA C точно соответствует типу int32 в NumPy; тип float в С точно соответствует типу float32 в NumPy. Для комплексных чисел используется внутренний шаблон PyCUDA - ::complex, и он является подобием complex64 в NumPy. Взглянем на содержимое второй строки, заданной при помощи тройных кавычек (“““). Это позволяет нам поместить несколько строк внутри одной. Мы будем пользоваться данным преимуществом при написании больших ядер CUDA в Python. Хотя передаваемые нами массивы являются двумерными в Python, для CUDA они являются одномерными и индексируются при помощи i. Функция ElementwiseKernel сама распределяет индексы по ядрам и нитям для нас. Мы инициализируем каждую точку в выходном массиве 1 при помощи mandelbrot_graph[i] = 1. Здесь i будет проходить через каждый элемент нашего множества Мандельброта, и можно считать, что каждая точка является элементом множества, пока не будет доказано противное. (Наше множество Мандельброта на самом деле двумерное, но функция ElementwiseKernel автоматически переведет все в одномерный массив. Когда мы вновь начнем обрабатывать данные в Python, двумерная структура множества Мандельброта будет сохранена.) Мы инициализируем переменную c соответствующей точкой сетки pycuda:: complex c = lattice[i] и инициализируем z числом 0 как pycuda::complex z(0,0)(первый ноль соответствует вещественной части, а второй – мнимой). Далее мы выполним цикл по j через for(int j = 0; j < max_iters; j++). (Обратите внимание, что алгоритм не распараллеливается по j – только по i! Весь этот цикл будет выполняться последовательно по j, но при этом параллельно по i.) Затем мы задаем новое значение для z через z = z*z + c, как в алгоритме Мандельброта. Если модуль значения данного элемента превышает верхнюю границу (if(abs(z) > upper_bound)), то мы устанавливаем для этой точки выходное значение 0 (mandelbrot_graph[i] = 0;) и выходим из цикла через оператор break.
Использование ElementWiseKernel из PyCUDA 61 В последней строке, передаваемой в ElementwiseKernel, мы дадим нашему ядру внутреннее для CUDA C имя "mandel_ker". Теперь можно запустить ядро. Единственное, что нам осталось сделать, – это заменить ссылку с simple_mandelbrot на gpu_mandelbrot, и мы готовы. Давайте запустим из IPython:
Проверим полученное изображение, чтобы убедиться, что оно верное:
Это то же самое изображение, что и полученное нами в главе 1, так что мы успешно перенесли алгоритм на GPU! Давайте теперь посмотрим, какой прирост скорости мы получили: в главе 1 нам понадобилось 14,6 с для получения изображения; сейчас мы потратили только 0,894 с. Имейте в виду, что PyCUDA необходимо было также откомпилировать и слинковать код на CUDA C, и, кроме того, копирование данных между CPU и GPU также занимает некоторое время. Однако даже с учетом всего этого у нас получился довольно неплохой прирост скорости! (Вы можете посмотреть на код в файле gpu_mandelbrot.py в репозитории на GitHub.)
Краткая вылазка в функциональное программирование Прежде чем мы продолжим, давайте вспомним две функции, имеющиеся в Python для программирования, – map и reduce. Они обе считаются действующими, так как оперируют функциями для своей работы. Нам это кажется интересным, поскольку они соответствуют распространенным паттернам проектирования в программировании и мы можем передавать разные функции на вход для получения множества (полезных) операций.
62
Начало работы с CUDA
Для начала предлагаю вспомнить ключевое слово lambda в Python. Оно позволяет нам определять как анонимные функции, которые обычно используются всего лишь раз или могут быть заданы одной строкой. Запустим IPython и определим функцию, которая возводит число в квадрат как pow2 =lambda x : x**2. Теперь проверим ее на нескольких числах:
Вспомним, что функция map принимает два входных аргумента – функцию и список объектов, к которым она будет применена. Результатом map является список, где каждому элементу соответствует результат функции для этого элемента. Давайте теперь определим операцию возведения в квадрат как анонимную функцию, передаваемую в map, и возьмем список чисел и проверим результат, полученный при ее применении, – map(lambda x : x**2, [2,3,4]):
Как мы видим, map работает, как ElementwiseKernel! Вообще, это стандартный паттерн в функциональном программировании. Обратим внимание на функцию reduce. Вместо того чтобы взять на вход список и вернуть тот, который соответствует входному, эта функция берет его и выполняет над ним рекурсивную бинарную операцию, возвращая единственное значение. Для понимания данного паттерна наберем reduce(lambda x, y : x + y, [1,2,3,4]). Когда мы введем это в IPython, то увидим, что он выведет единственное число 10, что является суммой 1 + 2 + 3 + 4. Вы можете попробовать заменить сложение умножением и убедиться, что это действительно работает для произведения длинного списка чисел. В общем случае мы используем reduce с ассоциативными бинарными операциями. Это означает, что независимо от того, в каком порядке мы станем выполнять операцию над последовательными элементами списка, всегда будет получаться один и тот же результат. (Не надо путать это со свойством коммутативности.) Далее рассмотрим, как в PyCUDA можно применять паттерны, аналогичные reduce, – с использованием параллельных ядер.
Использование ElementWiseKernel из PyCUDA 63
Основа параллельного сканирования и редуцирования Перейдем к рассмотрению основной функции в PyCUDA, воспроизводящей функциональность reduce, – InclusiveScanKernel. (Вы можете найти соответствующий код в файле simple_scankernel.py.) Для этого выполним пример, складывающий небольшой список чисел на GPU: import numpy as np import pycuda.autoinit from pycuda import gpuarray from pycuda.scan import InclusiveScanKernel seq = np.array([1,2,3,4],dtype=np.int32) seq_gpu = gpuarray.to_gpu(seq) sum_gpu = InclusiveScanKernel(np.int32, "a+b") print sum_gpu(seq_gpu).get() print np.cumsum(seq)
Мы строим наше ядро, сначала задавая входной/выходной тип (здесь это int32 из NumPy) и строку "a+b". Функция InclusiveScanKernel автоматически задает элементы с именами a и b, поэтому данная строка выглядит как аналог lambda a,b: a + b. Мы можем поместить сюда любую (ассоциативную) бинарную операцию, при условии что запишем ее в С. Если мы запустим sum_gpu, то увидим, что на выходе получим массив того же размера, что и у входного. Каждый элемент в этом массиве представляет собой значения для каждого шага вычисления (функция cumsum из NumPy выдает те же значения, как вы можете видеть). Последний элемент и будет тем самым выходным значением, которое нам нужно, соответствующим выходу reduce:
Следующим шагом попробуем произвести более интересные вычисления – найдем максимальное значение в массиве из чисел типа float32: import numpy as np import pycuda.autoinit from pycuda import gpuarray from pycuda.scan import InclusiveScanKernel seq = np.array([1,100,‑3,‑10000, 4, 10000, 66, 14, 21],dtype=np.int32) seq_gpu = gpuarray.to_gpu(seq) max_gpu = InclusiveScanKernel(np.int32, "a > b ? a : b") print max_gpu(seq_gpu).get()[-1] print np.max(seq)
(Вы можете просмотреть полный код в файле simple_scankernel1.py.) Здесь основным новшеством была замена строки a+b на a>b?a:b (в Python для этого нужно было бы использовать вызов reduce с функцией lambda a, b:max(a,b)). Чтобы получить максимальное значение, мы используем оператор ? языка С.
64
Начало работы с CUDA
В конце мы выводим последнее значение в выходном массиве, которое и будет тем максимумом, что нам нужен. Теперь давайте, наконец, посмотрим на еще одну функцию в PyCUDA для создания ядер для GPU – ReductionKernel. Она ведет себя, как ElementwiseKernel, за которым следует вызов параллельного сканирования. Какой алгоритм будет лучшим для использования ReductionKernel? Первое, что приходит в голову, – это скалярное произведение из линейной алгебры. Давайте вспомним, что вычисление скалярного произведения двух векторов состоит из двух шагов: 1) поэлементно перемножить векторы; 2) сложить полученные произведения. Эти два шага также называются умножить и накопить (multiply и accumulate). Перейдем к созданию самого ядра: dot_prod = ReductionKernel(np.float32, neutral="0", reduce_expr="a+b", map_expr="vec1[i]*vec2[i]", arguments="float *vec1, float *vec2")
Во-первых, обратите внимание на использованный нами тип данных для нашего ядра (float32). Затем мы зададим входные аргументы для ядра на CUDA C через параметр arguments (два массива из чисел с плавающей точкой. Каждый вектор представлен как float*) и поэлементную операцию через map_expr. Здесь это умножение. Как и с ElementwiseKernel, аргументы индексируются по i. После назначим reduce_expr, так же как и в InclusiveScanKernel. При выполнении данного процесса мы будем руководствоваться результатами, полученными путем поэлементного редуцирования. И наконец, мы зададим нейтральный элемент через neutral. Он выступает как нейтральный элемент по используемой нами операции. Для сложения это neutral=0 (для умножения нейтральным элементом была бы 1). Мы увидим, почему нам нужно было все это задавать, когда начнем детально разбирать параллельные операции далее.
резюме Сперва мы определились, как опросить параметры GPU из PyCUDA, для чего переписали программу deviceQuery на Python. Затем научились копировать массивы NumPy в и из GPU при помощи класса gpuarray из PyCUDA и его методов to_gpu и get. После этого посмотрели, как можно использовать объекты gpuarray для выполнения простых вычислений на GPU и узнали, как применять профайлер prun из IPython. Мы увидели, что при первой сессии при выполнении функций на GPU из PyCUDA иногда случается замедление в связи с тем, что библиотека запускает компилятор nvcc от NVIDIA для переделки кода на CUDA C. Плюс к этому разобрались, как использовать функцию ElementwiseKernel для компиляции и запуска поэлементных операций, которые автоматически распараллеливаются на GPU из Python. Мы провели короткий обзор функционального программирования в Python (в частности, функций map и reduce) и, наконец, рассмотрели, как выполнять некоторые базовые вычисления типа reduce/ scan на GPU при помощи функций InclusiveScanKernel и ReductionKernel.
Вопросы 65 Теперь, когда мы знаем основы написания и запуска ядер, мы должны четко понимать, что PyCUDA сделала очень много работы за нас по написанию ядер при помощи своих шаблонов. Всю следующую главу мы будем разбирать принципы выполнения ядер CUDA и то, как она организует параллельно выполняемые нити в абстрактные сетки (grid) и блоки (block).
вопросы 1. В simple_element_kernel_example0.py мы не учитывали копирование памяти в и из GPU при определении времени вычисления на процессоре. Попробуйте измерить время, затрачиваемое методами класса gpuarray to_gpu и get, при помощи функции time. Как вы считаете, стоило переносить функцию на GPU с учетом времени копирования? 2. В главе 1 «Почему программирование GPU?» мы обсудили закон Амдала, дающий нам некоторое представление о том, что мы можем выиграть при переносе частей кода на GPU. Назовите два момента, с которыми вы сталкивались в данной главе, не учитываемые законом Амдала. 3. Измените gpu_mandel0.py для использования меньших сеток комплексных чисел и сравните с CPU-версией программы. Можем ли мы выбрать достаточно маленькую сетку, чтобы версия для CPU была быстрее версии для GPU? 4. Создайте ядро при помощи ReductionKernel, которое берет два массива из complex64 одинаковой длины и возвращает наибольший по модулю элемент из обоих массивов. 5. Что случится, когда объект класса gpuarray выйдет из области своего существования в Python? 6. Как вы думаете, зачем нам нужно вводить команду neutral при использовании ReductionKernel? 7. Если в ReductionKernel мы задали reduce_expr ="a > b ? a : b" и работаем с типом int32, то каким мы должны задать neutral?
Глава
4 Ядра, нити, блоки и сетки
В данной главе мы увидим, как писать эффективные ядра CUDA. В программировании GPU ядро (этот термин мы будем использовать как синоним ядра CUDA или функции ядра) – это параллельная функция, которая может быть запущена прямо с хоста (CPU) на устройство (GPU), в то время как функция устройства – это функция, которая может быть вызвана или из ядра, или из другой функции устройства. (Вообще, функции устройства выглядят и ведут себя как обычные последовательные функции С/С++ с тем отличием, что они выполняются на GPU и параллельно вызываются из ядер.) Далее мы разберемся, как CUDA использует такие понятия, как «нити», «блоки» и «сетки», для абстрагирования от технических подробностей GPU (таких как ядра, варпы и потоковые мультипроцессоры, которые мы рассмотрим далее в книге) и как мы можем применять эти понятия для облегчения понимания параллельного программирования. Также мы узнаем о синхронизации нитей (как на уровне блока, так и на уровне сетки) и межнитевом взаимодействии в CUDA при помощи глобальной и разделяемой памяти. И наконец, мы погрузимся в технические детали того, как реализовать наши собственные алгоритмы для параллельного нахождения префиксной суммы на GPU (т. е. функции типа scan/reduce, которые мы рассмотрели в предыдущей главе), что позволит нам применить на практике все усвоенные принципы. В результате работы с этой главой вы узнаете, как: отличать ядро от функции устройства; компилировать и создавать ядро в PyCUDA и использовать функции устройства в ядре; эффективно применять нити, блоки и сетки в контексте запуска ядра и пользоваться threadIdx и blockIdx в ядре; и зачем синхронизовать нити в пределах ядра, используя при этом как функцию __syncthreads() для синхронизации нитей в пределах блока, так и хост для синхронизации всех нитей на сетке блоков; использовать глобальную и разделяемую память устройства для межнитевого взаимодействия; использовать все полученное нами знание для правильной реализации на GPU параллельного префиксного суммирования.
Ядра 67
технические требования Для работы с данной главой нам требуется ПК с Linux или Windows 10 и современным GPU (2016 г. или новее) со всеми необходимыми драйверами GPU и CUDA Toolkit (9.0 или выше). Также понадобится подходящая установка Python 2.7 (например, Anaconda Python 2.7) с модулем PyCUDA. Весь код этой главы доступен на GitHub по адресу: https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA. За дополнительной информацией о требованиях обратитесь к разделу «Предисловие» этой книги; требования по устройствам и программному обеспечению вы можете найти в файле README на сайте https://github.com/PacktPublishing/Hands-On-GPU-Programming-with-Python-and-CUDA.
яДра Как и в предыдущей главе, мы будем учиться писать ядра на CUDA C прямо в нашем коде на Python и запускать их на нашем GPU при помощи PyCUDA. Там мы использовали шаблоны, предоставляемые данной библиотекой, для того чтобы писать ядра, соответствующие этим образцам проектирования. В отличие от этого, сейчас мы будем учиться писать наши ядра с самого нуля, так что сможем создавать сразу множество различных ядер, не входящих ни в один из конкретных шаблонов, предоставляемых PyCUDA, и потому получим более четкий контроль за ядрами. Конечно, за все это придется расплачиваться увеличившейся сложностью программирования. Нам следует получить представление о нитях, блоках и сетках и их роли для ядер, а также о том, как синхронизовать нити, выполняющие наше ядро, и понять, как можно обмениваться данными между нитями. Давайте начнем с самого простого и попробуем создать заново некоторые из поэлементных операций, что мы встретили в прошлой главе. Однако на этот раз мы не будем использовать функцию ElementwiseKernel. Теперь же мы применим функцию SourceModule. Это очень могущественная функция в PyCUDA, позволяющая создать ядро с самого начала, поэтому лучше будет начать с чего-то простого.
Функция SourceModule из PyCUDA Мы будем использовать функцию SourceModule из PyCUDA, для того чтобы компилировать код на CUDA C в выполнимые ядра, которые мы сможем вызывать из Python. Следует заметить, что SourceModule на самом деле компилирует код в модуль CUDA, который напоминает модуль Python или DLL Windows; только он содержит откомпилированный код CUDA. Это значит, что нам придется «вытаскивать» ссылку на ядро, которое мы хотим использовать в PyCUDA, при помощи функции get_function, прежде чем мы сможем его запустить. Давай-
68
Ядра, нити, блоки и сетки
те начнем с простого примера, а именно с того, как использовать ядро CUDA вместе с SourceModule. Как и ранее, мы начнем с одного из наиболее простых возможных ядер – умножения вектора на скаляр. Приступим к проведению процедуры импортирования нужных модулей и функций: import pycuda.autoinit import pycuda.driver as drv import numpy as np from pycuda import gpuarray from pycuda.compiler import SourceModule
Теперь мы можем написать наше ядро: ker = SourceModule(""" __global__ void scalar_multiply_kernel(float *outvec, float scalar, float *vec) { int i = threadIdx.x; outvec[i] = scalar*vec[i]; } """)
Сейчас предлагаю остановиться и сравнить результат с тем, что у нас выходило при использовании ElementwiseKernel. Во-первых, когда мы объявляем функцию ядра в CUDA C, то начинаем с ключевого слова __global__. Благодаря этому компилятор поймет, что перед ним не просто функция, а ядро. Мы всегда объявляем ядро как void, поскольку всегда будем получать выходные значения, передавая адрес блока памяти как параметр. Мы можем объявить параметры так же, как и в любой другой обычной функции С: сначала у нас идет outvec, который будет соответствовать отмасштабированному выходному вектору и являться указателем на массив из чисел с плавающей точкой. Дальше у нас идет scalar, который имеет тип float. Обратите внимание, что это не указатель! Если мы хотим просто передать обычные значения в наше ядро, то можно всегда сделать это без использования указателей. Наконец, у нас идет наш входной вектор vec, который также является указателем на массив из чисел с плавающей точкой. Обычные скалярные параметры могут быть переданы в функцию ядра напрямую из хоста без использования указателей или выделенной памяти устройства.
Давайте внимательно посмотрим на наше ядро, прежде чем приступим к его тестированию. Мы помним, что функция ElementwiseKernel автоматически распараллеливала вычисления на нити GPU по параметру i, который задавался для нас PyCUDA. Задание каждой отдельной нити идет через значение threadIdx, извлекаемое следующим образом: int i = threadIdx.x;
Ядра 69 Величина threadIdx используется для того, чтобы сообщить каждой отдельной нити ее номер. Обычно при помощи ее задается индекс для обращения к значениям во входных и выходных массивах. (Также оно может быть использовано для задания отдельным нитям отличных задач при помощи стандартных операторов управления, таких как if или switch.)
Теперь мы готовы выполнить наше умножение параллельно при помощи той же конструкции, что и ранее: outvec[i] = scalar*vec[i];. Итак, проверим наш код. Сначала мы должны получить ссылку на нашу откомпилированную функцию ядра из только что скомпилированного при помощи SourceModule модуля CUDA. Ее можно взять, набрав команду get_function, как показано ниже: scalar_multiply_gpu = ker.get_function("scalar_multiply_kernel")
Для того чтобы проверить ядро, нам необходимо поместить любые данные на GPU. Давайте создадим массив из 512 случайных чисел с плавающей точкой и скопируем их в глобальную память GPU при помощи метода gpuarray.to_gpu. (Мы собираемся умножить этот вектор из случайных чисел на скалярное значение и на CPU, и на GPU и проверить, совпадают ли результаты.) Также мы выделим блок свободной памяти из глобальной памяти GPU при помощи метода gpuarray.empty_like: testvec = np.random.randn(512).astype(np.float32) testvec_gpu = gpuarray.to_gpu(testvec) outvec_gpu = gpuarray.empty_like(testvec_gpu)
Сейчас мы начинаем запуск нашего ядра. В качестве скалярного значения возьмем 2. (Нет необходимости копировать это скалярное значение на GPU. Однако нам нужно быть аккуратными при приведении одного типа к другому.) Нам требуется задать число нитей равным 512 при помощи параметров block и grid. Теперь мы готовы к запуску: scalar_multiply_gpu( outvec_gpu, np.float32(2), testvec_gpu, block=(512,1,1), grid=(1,1,1))
Сейчас проверим, совпадают ли выходные значения с тем, что мы ожидаем получить, используя метод get, у нашего gpuarray для получения данных и последующем сравнении с точным результатом, используя функцию allclose из NumPy: print "Does our kernel work correctly? : {}".format(np.allclose(outvec_gpu.get() , 2*testvec) )
(Код к этому примеру содержится в файле simple_scalar_multiply_kernel.py в каталоге 4 репозитория.) Мы подошли к тому шагу, когда можем без последствий избавиться от поддержки шаблонов PyCUDA, с которыми имели дело в предыдущей главе, и начать писать ядра сразу на CUDA C с дальнейшим запуском их с заданным чис-
70
Ядра, нити, блоки и сетки
лом нитей на нашем GPU. Однако нам нужно немного узнать о том, как CUDA организует нити в наборы абстрактных групп, называемых блоками и сетками (grid), прежде чем мы сможем продолжить изучать ядра.
нити, блоки и сетки До сих пор в этой книге мы просто принимали понятие нить как данное. Давайте сделаем шаг назад и посмотрим, что оно на самом деле значит. Нить – это последовательность команд, которые выполняются на одном ядре GPU. Ядра и нити не должны рассматриваться как синонимичные понятия! На самом деле можно запускать ядра, которые используют гораздо больше нитей, чем имеется ядер в GPU. Это происходит аналогично тому, как и в процессорах Intel может быть всего четыре ядра, но при этом будут выполняться сотни процессов и тысячи нитей в Linux или Windows. Из-за того что планировщик способен переключаться между этими задачами очень быстро, создается впечатление, что они все выполняются одновременно. GPU может работать с нитями подобным образом, позволяя производить десятки тысяч нитей. Многочисленные нити, выполняемые на GPU, группируются в абстрактные группы, называемые блоками. Как вы помните, в ядре для умножения вектора мы получали идентификатор нити из threadIdx.x. В конце идет х, поскольку на самом деле есть еще threadIdx.y и threadIdx.z. Это происходит потому, что вы можете провести адресацию блоками по трем измерениям, а не по одному. Зачем мы это делаем? Давайте вспомним пример, касающийся вычисления множества Мандельброта, из глав 1 и 3. Мы проводим вычисления точка за точкой на двумерной плоскости. Поэтому для нас может быть более удобным индексирование нити по двум измерениям для подобных алгоритмов. Аналогично в некоторых случаях имеет смысл использовать три измерения – в физическом моделировании нам может понадобиться вычисление положения двигающихся точек внутри трехмерной сетки. Блоки также выполняются абстрактными группами, называемым сетками (grid), которые проще всего представлять как блоки блоков. Как и с нитями, мы можем индексировать блоки до трех измерений, используя для этого значения, заданные в blockIdx.x, blockIdx.y и blockIdx.z. Давайте обратимся к примеру, который поможет нам разобраться с данными понятиями. При этом для простоты мы будем использовать лишь два измерения.
Игра «Жизнь» Джона Конвея Игра «Жизнь» Джона Конвея – это моделирование клеточного автомата, изобретенного британским математиком Джоном Конвеем в 1970 г. Звучит сложно, но на самом деле все очень просто. «Жизнь» – это игра без игроков, состоящая из двумерной сетки клеток, каждая из которых может быть либо живой, либо мертвой. Эта сетка итеративно обновляется по следующим правилам:
Нити, блоки и сетки 71 любая живая клетка менее чем с двумя соседями умирает; любая живая клетка с двумя или тремя соседями выживает; любая живая клетка более чем с тремя соседями умирает; любая мертвая клетка ровно с тремя соседями становится живой. Эти четыре простых правила приводят к довольно сложному поведению с интересными математическими свойствами и при анимации выглядят очень красиво. Однако с большим числом клеток процесс моделирования может проходить довольно медленно, и обычно оно приводит к рваной анимации, когда реализуется на обычном последовательном Python. Однако это легко распараллеливается, поскольку ясно, что каждая отдельная клетка может просчитываться отдельной нитью CUDA. Сейчас мы реализуем «Жизнь» как ядро CUDA и анимируем его при помощи модуля matplotlib.animation. Это будет интересно для нас, поскольку позволит применить новые знания о блоках и сетках. Мы начнем с подключения соответствующих модулей, как показано ниже: import pycuda.autoinit import pycuda.driver as drv from pycuda import gpuarray from pycuda.compiler import SourceModule import numpy as np import matplotlib.pyplot as plt import matplotlib.animation as animation
Теперь давайте погрузимся в написание ядра через SourceModule. Мы начнем с использования директивы #define языка С для задания нескольких констант и макросов, которые будем использовать в нашем ядре. Посмотрим на задание первых двух: ker = SourceModule(""" #define _X ( threadIdx.x + blockIdx.x * blockDim.x ) #define _Y ( threadIdx.y + blockIdx.y * blockDim.y )
Для начала вспомним, как работает #define, – он просто заменяет любое вхождение _X и _Y на заданные значения (заданные здесь в скобках) во время компиляции, т. е. создает для нас макросы. (Лично я всегда начинаю писать имена макросов с подчеркивания.) В С и С++ #define используется для создания макросов. Это значит, что #define не создает никаких функций и константных переменных – эта конструкция просто позволяет нам делать текст короче за счет подстановки прямо перед моментом компиляции.
Теперь поговорим о том, что значат _X и _Y. Это будут декартовы координаты x и y клетки, соответствующей текущей нити CUDA на двумерной сетке, которую мы будем использовать для игры «Жизнь». Мы запустим ядро над двумерной сеткой, состоящей из двумерных блоков, покрывая тем самым все клетки нашей игры. Для определения декартовых координат клетки нам нужно использовать номера и нити, и блоков. Далее посмотрим на несколько карти-
72
Ядра, нити, блоки и сетки
нок, данных для разъяснения. Нить, расположенная внутри двумерного блока CUDA, может быть показана следующим образом:
Вы, может быть, удивитесь, почему мы не хотим запустить наши ядра для всего блока так, чтобы задать _X как threadIdx.x и _Y как threadIdx.y. Это связано с наличием ограничений на размер блока, накладываемых CUDA. На данный момент поддерживаются блоки, содержащие не более 1024 нитей. Это значит, что таким образом мы можем работать с сеткой клеток размером 32×32 максимум, что будет выглядеть довольно скучным моделированием и легко выполнимо на CPU. Поэтому нам нужно запустить много блоков в сетке. (Размер нашего блока будет передаваться в blockDim.x и blockDim.y, что поможет определить целевые координаты x и y, как вы увидите далее.) Как и ранее, мы можем определить, в каком блоке находимся внутри двумерной сетки, при помощи blockIdx.x и blockIdx.y.
Если немного подумать об используемой математике, то станет понятным, что _X должен быть определен как (threadIdx.x + blockIdx.x * blockDim.x) и _Y дол-
Нити, блоки и сетки 73 жен быть определен как ( threadIdx.y + blockIdx.y * blockDim.y ). (Скобки были добавлены, чтобы не влиять на порядок операций при вставке макросов в код.) Теперь продолжим операции с оставшимися макросами: #define #define #define #define
_WIDTH ( blockDim.x * gridDim.x ) _HEIGHT ( blockDim.y * gridDim.y ) _XM(x) ( (x + _WIDTH) % _WIDTH ) _YM(y) ( (y + _HEIGHT) % _HEIGHT )
Макросы _WIDTH и _HEIGHT задают нам ширину и высоту сетки клеток соответственно, что должно быть видно из приведенных рисунков. Давайте обсудим макросы _XM и _YM. В реализации «Жизни» нам нужно будет «заворачивать» некоторые значения к противоположному краю сетки – например, мы будем считать, что при x = –1 надо на самом деле обращаться к _WIDTH‑1 и при y = –1 – к _HEIGHT‑1. Аналогично мы вычислим значение x = _WIDTH как 0 и значение y = _HEIGHT как 0. Зачем нам это нужно? При нахождении числа «живых» соседей заданной клетки мы можем находиться на границе, а соседи могут выходить за границу. Для этого мы используем данные макросы, чтобы приводить точки внутрь сетки. Обратите внимание, что нам приходится прибавлять ширину или высоту перед оператором остатка от деления. Это нужно, потому что в С, в отличие от Python, оператор остатка от деления может возвращать отрицательные значения. Теперь нам осталось определить еще один макрос. Как мы помним, PyCUDA передает двумерные массивы в CUDA C как указатели на одномерные; двумерные массивы передаются по строкам (rowwise) через одномерные указатели С. Это значит, что нам придется переводить двумерные декартовы координаты (x, y) для клетки на сетке в соответствующий одномерный указатель. Это мы можем сделать следующим образом: #define _INDEX(x,y) ( _XM(x) + _YM(y) * _WIDTH )
Поскольку сетка хранится по строкам, нам необходимо умножить значение y на ширину для перехода к соответствующей строке. Наконец, мы можем начать нашу реализацию «Жизни». Для начала приступим к самой важной части «Жизни» – вычислению числа «живых» соседей для клетки. Мы запишем это как функцию устройства CUDA, как показано ниже: __device__ int nbrs(int x, int y, int * in) { return ( in[ _INDEX(x ‑1, y+1) ] + in[ _INDEX(x‑1, y) ] + in[ _INDEX(x‑1, y‑1) ] + in[ _INDEX(x, y+1)] + in[_INDEX(x, y ‑ 1)] + in[ _INDEX(x+1, y+1) ] + in[ _INDEX(x+1, y) ] + in[_INDEX(x+1, y‑1) ] ); }
Функция устройства – это обычная последовательная функция языка С, которая может быть вызвана отдельной нитью CUDA. То есть это небольшая функция, которая может быть вызвана параллельно многими нитями, выполняющими наше ядро. Мы представим нашу сетку клеток как набор 32-битовых
74
Ядра, нити, блоки и сетки
целых чисел (1 представляет живую клетку; 0 – мертвую), что позволит нам просто складывать значения соседних клеток. Хотя сами эти функции последовательны, они могут выполняться на большом числе нитей GPU. Функции устройства не могут быть запущены со стороны CPU как ядра.
Теперь мы готовы написать ядро нашей реализации игры «Жизнь». На самом деле мы уже проделали основную часть тяжелой работы, проверяя число соседей для клетки, соответствующей текущей нити, и тот факт, является текущая клетка живой или мертвой. После этого можно использовать соответствующий оператор switch для определения ее статуса на следующей итерации игры «Жизнь». Мы возьмем в расчет два массива целых чисел в ядре – один будет ссылаться на результаты прошлой итерации как входные данные (lattice), а второй – ссылаться на результат работы текущей итерации (lattice_out): __global__ void conway_ker(int * lattice_out, int * lattice ) { // x, y соответствует клетке для данной нити int x = _X, y = _Y; // Считаем число соседей для текущей клетки int n = nbrs(x, y, lattice); // Если текущая клетка жива, то определяем, выживет она // или умрет в следующем поколении if ( lattice[_INDEX(x,y)] == 1) switch(n) { // Если клетка жива: она остается живой, только если // у нее 2 или 3 соседа. case 2: case 3: lattice_out[_INDEX(x,y)] = 1; break; default: lattice_out[_INDEX(x,y)] = 0; } else if( lattice[_INDEX(x,y)] == 0 ) switch(n) { // Мертвая клетка становится живой, если у нее 3 живых соседа. case 3: lattice_out[_INDEX(x,y)] = 1; break; default: lattice_out[_INDEX(x,y)] = 0; } } """) conway_ker = ker.get_function("conway_ker")
Помним, что от нас требуется закрыть вложенный код на CUDA C при помощи тройных кавычек и получить ссылку на ядро CUDA, применяя функцию get_function. Поскольку ядро выполняет только одну итерацию обновления сетки клеток, то мы напишем небольшую функцию на Python, которая возьмет на себя всю работу по обновлению сетки для создания анимации:
Нити, блоки и сетки 75 def update_gpu(frameNum, img, newLattice_gpu, lattice_gpu, N):
Параметр frameNum – это значение, которое необходимо для модуля анимации matplotlib, что мы можем игнорировать, а img – это изображение нашей сетки, которое будет показываться. Давайте сконцентрируемся на трех оставшихся параметрах. NewLattice_gpu и lattice_gpu будут массивами PyCUDA, которые мы собираемся использовать постоянно, поскольку хотим избежать частого перевыделения памяти GPU. Функция lattice_gpu покажет текущее поколение клеток, соответствующее lattice в ядре, а newLattice_gpu – следующее поколение клеток. N будет обозначать ширину и высоту сетки клеток (другими словами, мы планируем работать с сеткой N×N клеток). Далее запускаем ядро с соответствующими параметрами и задаем размеры блока и сетки следующим образом: conway_ker(newLattice_gpu, lattice_gpu, grid=(N/32,N/32,1), block=(32,32,1) )
Мы задаем размер блока 32×32 как (32,32,1). Поскольку мы будем использовать лишь два измерения из трех, то можем задать размер по z, равный 1. Помните, что размер блока ограничен 1024 нитями – 32×32 = 1024, поэтому это подойдет. (Имейте в виду, что нет ничего особенного в выборе 32×32. Мы на самом деле могли использовать другие значения, такие как 16×16 или 10×10, при условии что общее число нитей не превышает 1024.) Число нитей в блоке CUDA ограничено 1024 нитями.
Теперь обратимся к размеру сетки. Так как мы используем блок размером 32×32, то становится ясно, что N (в этом случае) должно быть кратно 32. Это значит, что в нашем случае мы ограничены сетками размерами 64×64, 96×96, 128×128 и 1024×1024. Опять, если мы хотим использовать сетки других размеров, то необходимо изменить размеры блока. (Если это не понятно, то обратитесь к приведенным ранее рисункам и посмотрите, как мы определили макросы для ширины и высоты в ядре.) Для подготовки данных для анимации нужно взять последнюю сгенерированную сетку клеток при помощи функции get. Мы копируем данные новой сетки клеток в текущую клетку при помощи оператора [:] из PyCUDA, который просто перепишет данные в ранее выделенную память GPU, так что нам не придется вновь ее выделять: img.set_data(newLattice_gpu.get() ) lattice_gpu[:] = newLattice_gpu[:] return img
Мы создадим сетку клеток размером 256×256. Начальное состояние для этой сетки мы построим при помощи модуля numpy.random. Затем заполним нашу сетку клеток нулями и единицами так, чтобы примерно 25 % было единицами, а остальное – нулями. Это позволит нам получить интересные анимации:
76
Ядра, нити, блоки и сетки
if __name__ == '__main__': # задаем размер решетки N = 256 lattice = np.int32( np.random.choice([1,0], N*N, p=[0.25, 0.75]).reshape(N, N) ) lattice_gpu = gpuarray.to_gpu(lattice)
Наконец, мы можем скопировать наши данные на GPU при помощи методов класса gpuarray и задать анимацию в matplotlib следующим образом: lattice_gpu = gpuarray.to_gpu(lattice) lattice_gpu = gpuarray.to_gpu(lattice) newLattice_gpu = gpuarray.empty_like(lattice_gpu) fig, ax = plt.subplots() img = ax.imshow(lattice_gpu.get(), interpolation='nearest') ani = animation.FuncAnimation(fig, update_gpu, fargs=(img, newLattice_gpu, lattice_gpu, N, ) , interval=0, frames=1000, save_count=1000) plt.show()
Осталось запустить нашу программу и наслаждаться видео (этот код содержится в файле conway_gpu.py в каталоге 4 репозитория на GitHub.)
Синхронизация и взаимодействие нитей 77
синхронизация и взаимоДействие нитей Далее мы рассмотрим два важных понятия в программировании GPU – синхронизацию нитей и взаимодействие нитей. Иногда нам нужно убедиться, что каждая нить достигла одной и той же строки кода, прежде чем мы можем продолжить вычисления. Мы называем это синхронизацией нитей. Синхронизация идет рука об руку со взаимодействием нитей, т. е. когда отдельные нити передают и читают данные друг от друга. В этом случае нам необходимо убедиться, что все нити находятся на одном и том же шаге вычислений, прежде чем можно будет передавать данные. Мы начнем с изучения функции __syncthreads из CUDA, которая используется для синхронизации одного блока.
Использование функции устройства __syncthreads() В предыдущем примере игры «Жизнь» Джона Конвея наше ядро обновляло сетку клеток только один раз за вызов со стороны хоста. Поскольку мы работали с готовой итерацией сетки клеток, то не возникало никаких проблем с синхронизацией нитей запущенного ядра. Допустим, мы хотим сделать что-то слегка отличающееся – например, переписать наше ядро так, что оно выполнит заданное число итераций на сетке клеток без перезапуска со стороны хоста. Нам кажется, что простым, тривиальным решением будет добавить целочисленный параметр, задающий число итераций, и цикл for внутри нашего ядра conway_ker, затем осуществить несколько дополнительных несложных изменений, и все готово. Однако при этом возникает вопрос о состоянии гонки (race condition). Это когда сразу несколько нитей читают и пишут по одному адресу в памяти, из-за чего могут возникать различные проблемы. Наше старое ядро conway_ker избегает появления такой неприятности за счет использования двух массивов в памяти: из одного только читают, а в другой только пишут. Поскольку ядро выполняет всего одну итерацию, мы фактически используем хост для синхронизации наших нитей. Мы желаем выполнить несколько итераций «Жизни» на GPU, которые полностью синхронизированы. Также мы хотим использовать всего лишь один массив в памяти для всей сетки клеток. Состояния гонки можно избежать благодаря использованию функции устройства CUDA __syncthreads(), которая является барьером синхронизации на уровне блока. Это означает, что любая нить, которая выполняется внутри блока, остановится, когда достигнет вызова __syncthreads(), и будет ждать, пока все остальные нити того же блока не достигнут его, прежде чем она сможет продолжить выполнение. __syncthreads() может синхронизировать нити только одного блока, а не всей сетки.
Давайте напишем наше новое ядро, которое, являясь модификацией предыдущего, выполнит заданное число итераций и остановится. Это значит, что мы
78
Ядра, нити, блоки и сетки
будем показывать результат не как анимацию, а как статичное изображение. Поэтому вначале загрузим соответствующие модули Python. (Этот код доступен в файле conway_gpu_syncthreads.py в репозитории на GitHub.) import pycuda.autoinit import pycuda.driver as drv from pycuda import gpuarray from pycuda.compiler import SourceModule import numpy as np import matplotlib.pyplot as plt
Теперь зададим наше ядро, которое будет вычислять «Жизнь»: ker = SourceModule("""
Конечно, ниже будет приведен код на CUDA C, который почти не отличается от того, что применялся ранее. Однако нужно будет внести некоторые изменения в наше ядро. Да, мы можем сохранить функцию устройства nbrs. В описании ядра мы планируем работать всего лишь с одним массивом для представления сетки клеток. Это можно сделать, поскольку будет использоваться надлежащая синхронизация нитей. Также нам нужно задать число итераций при помощи целого числа. Поэтому мы запишем параметры следующим образом: __global__ void conway_ker(int * lattice, int iters) {
Мы продолжим, как и ранее, только добавим итерирование при помощи цикла for: int x = _X, y = _Y; for (int i = 0; i < iters; i++) { int n = nbrs(x, y, lattice); int cell_value;
Давайте вспомним, что до этого мы явно задавали новое значение клетки в массиве. Сейчас мы сохраним данное значение в переменной cell_value до момента синхронизации всех нитей блока. Мы продолжим, как и ранее, блокировать выполнение нитей при помощи вызова __syncthreads() до тех пор, пока все новые значения клеток не будут определены для текущей итерации, и только тогда запишем эти значения в массив: if ( lattice[_INDEX(x,y)] == 1) switch(n) {
Синхронизация и взаимодействие нитей 79 // если клетка жива, то она остается живой, только // если у нее 2 или 3 живых соседа. case 2: case 3: cell_value = 1; break; default: cell_value = 0; } else if( lattice[_INDEX(x,y)] == 0 ) switch(n) { // мертвая клетка становится живой, если // у нее 3 живых соседа. case 3: cell_value = 1; break; default: cell_value = 0; } __syncthreads(); lattice[_INDEX(x,y)] = cell_value; __syncthreads(); } } """)
Теперь запустим наше ядро, но покажем вывод после 1 000 000 итераций. Обратите внимание, что сейчас мы используем в нашей сетке только один блок, который имеет размер 32×32 из-за ограничения на 1024 нити на блок. (Напоминаем, что __syncthreads() работает только среди всех нитей одного блока. Именно поэтому мы и используем всего один блок.) Итак: conway_ker = ker.get_function("conway_ker") if __name__ == '__main__': # зададим размер сетки N = 32 lattice = np.int32( np.random.choice([1,0], N*N, p=[0.25, 0.75]).reshape(N, N) ) lattice_gpu = gpuarray.to_gpu(lattice) conway_ker(lattice_gpu, np.int32(1000000), grid=(1,1,1), block=(32,32,1)) fig = plt.figure(1) plt.imshow(lattice_gpu.get())
Когда мы запустим программу, то получим выходное изображение, как показано ниже (это то, к чему наша случайная сетка клеток сошлась после 1 000 000 итераций!):
80
Ядра, нити, блоки и сетки
Использование разделяемой памяти Мы видим из предыдущего примера, что нити внутри ядра могут взаимодействовать между собой, используя массивы в глобальной памяти GPU. Хотя для большинства операций можно применять глобальную память, мы можем ускорить расчеты за счет применения разделяемой памяти. Этот тип памяти специально предназначен для взаимодействия между нитями одного блока CUDA. Преимуществом по сравнению с глобальной памятью является то, что она гораздо быстрее для взаимодействия нитей. Однако, в отличие от глобальной памяти, хост не может напрямую обратиться к разделяемой, так как ядро само должно скопировать данные в глобальную память. Но, прежде чем двигаться дальше, предлагаю вернуться на шаг назад и подумать о том, что имелось в виду. Обратим внимание на некоторые из переменных, которые были объявлены в нашем итерационном ядре «Жизни», что мы недавно написали. Для начала посмотрим на x и y, две целочисленные переменные, содержащие декартовы координаты клетки для текущей нити. Как вы помните, мы задаем их значения при помощи макросов _X и _Y. (Не беря в расчет оптимизацию компилятора, мы хотим сохранить эти значения
Синхронизация и взаимодействие нитей 81 в переменных, чтобы избежать их явного повторного вычисления при каждом обращении к данным макросам.) int x = _X, y = _Y;
Обратите внимание, что для каждой нити будет своя уникальная точка на сетке со своими x и y. Аналогично мы используем переменную n, которая объявлена как int n = nbrs(x, y, lattice), для обозначения числа «живых» соседей заданной клетки. Обычно, когда мы объявляем переменные, они являются локальными для каждой нити. Но даже если мы объявим массив внутри нити, например int a[10];, то у каждой будет свой локальный массив размером 10. Локальные массивы внутри нити (например, через объявление int a[10]; внутри ядра) и указатели на глобальную память GPU (например, значение, переданное в ядро как параметр вида int * b) могут вести себя похожим образом, но на самом деле здорово отличаться друг от друга. Для каждой нити в ядре создается свой локальный массив a, который другие нити не смогут читать, но при этом один и тот же b для всех нитей, содержащий одни и те же значения, которые одинаково доступны для всех нитей.
Мы готовы использовать разделяемую память, что позволит нам объявлять переменные и массивы, которые являются общими для всех нитей в пределах блока. Эта память гораздо быстрее, чем глобальная (которую мы использовали до сих пор) и может также уменьшить расход на ее выделение. Пусть, например, мы хотим создать массив из 10 целых чисел в разделяемой памяти. Объявим его следующим образом: __shared__ int a[10]. Обратите внимание, что мы не ограничиваем себя массивами; мы можем размещать в разделяемой памяти и одиночные переменные: __shared__ int x . Давайте перепишем несколько строк из итеративной версии «Жизни» для использования разделяемой памяти. Для начала переименуем входной указатель в p_lattice, чтобы у нас появилась возможность использовать это имя для массива в разделяемой памяти, не меняя всех упоминаний о нем в коде. Поскольку мы будем использовать сетку клеток размером 32×32, то объявим массив в разделяемой памяти следующим образом: __global__ void conway_ker_shared(int * p_lattice, int iters) { int x = _X, y = _Y; __shared__ int lattice[32*32];
Теперь нам нужно скопировать все значения из массива глобальной памяти по адресу p_lattice в lattice. Мы будем обращаться к массиву в разделяемой памяти точно так же, как и к массиву в глобальной памяти, используя для этого макрос _INDEX. Обратите внимание, что нам необходимо вызвать __syncthreads() после копирования, для того чтобы гарантировать, что все операции после окончания данного процесса были успешно завершены. Лишь после этого мы можем продолжить работу с алгоритмом «Жизни»:
82
Ядра, нити, блоки и сетки
lattice[_INDEX(x,y)] = p_lattice[_INDEX(x,y)]; __syncthreads();
Оставшаяся часть ядра такая же, как и прежде, за исключением того, что нам нужно скопировать сетку клеток из разделяемой памяти обратно в глобальную. Мы выполняем это, как показано ниже, и закрываем блок кода на CUDA C: p_lattice[_INDEX(x,y)] = lattice[_INDEX(x,y)]; __syncthreads(); } """)
Итак, настал момент для запуска нашей программы. (Этот пример можно найти под именем conway_gpu_syncthreads_shared.py в репозитории GitHub.)
алгоритм параллельной префиксной суммы Теперь мы воспользуемся нашим знанием ядер CUDA для реализации алгоритма параллельной префиксной суммы, также известной как сканирование. Мы уже видели простые примеры указанного алгоритма в виде функций InclusiveScanKernel и ReductionKernel в PyCUDA в прошлой главе. Опишем эту идею подробнее. В данном шаблоне проектирования у нас имеются бинарный оператор ⊕, т. е. функция, принимающая два входных значения и выдающая одно выходное (такая как –, +, ×, ∧ (максимум), ∨ (минимум)), и набор элементов x0, x1, x2, …, xn–1. Мы собираемся вычислить x0 ⊕ x1 ⊕ x2 ⊕ … ⊕ xn–1. При этом предполагаем, что наш бинарный оператор ассоциативен – это значит, что для любых трех элементов, x, y и z, всегда справедливо x ⊕ (y ⊕ z) = (x ⊕ y) ⊕ z. Также мы хотим сохранить промежуточные результаты, т. е. n – 1, частичное значение – x0, x0 ⊕ x1, x0 ⊕ x1 ⊕ x2, …, x0 ⊕ … ⊕ xn–2. Целью алгоритма параллельной префиксной суммы является получение этого набора из n значений эффективным способом. Для последовательного вычисления требуется O(n) операций, и нашей задачей является уменьшение затрачиваемого времени. Когда используется термин «параллельная префиксная сумма» или «скан», то обычно подразумевается алгоритм, который выдает эти n значений, в то время как термин «редукция» обозначает алгоритм, выдающий всего лишь одно окончательное значение: x0 ⊕ … ⊕ xn–1.
Есть несколько вариантов алгоритма нахождения параллельной префиксной суммы. Мы начнем с самой простейшей (и наиболее старой) версии, которая называется «наивная» параллельная префиксная сумма.
Алгоритм наивный параллельной префиксной суммы Наивный алгоритм параллельной префиксной суммы является оригинальной версией этого алгоритма. Он считается «наивным», поскольку делает предположение, что при наличии n входных элементов (считая n степенью
Алгоритм параллельной префиксной суммы 83 двух) мы можем запустить алгоритм параллельно на n процессорах (или нитях). Естественно, что это накладывает серьезные ограничения на n, чтобы мы могли запустить алгоритм. Однако при условии, что эти требования выполнены, мы получаем вычислительную сложность всего O(log n). Это видно из приводимого ниже псевдокода алгоритма. Здесь через x0, …, xn–1 обозначены входные значения и через y0, …, yn–1 – выходные. input: x0, ..., xn–1 initialize: for k=0 to n‑1: yk := xk begin: parfor i=0 to n–1 : for j=0 to log2(n): if i >= 2j : yi := yi ⊕ yi – 2j else: continue end if end for end parfor end output: y0, ..., yn–1
Теперь мы видим, что выполнение занимает O(log n) по времени, так как внешний цикл распараллеливается через parfor, а внутренний – занимает log(n). Если немного подумать, то станет ясно, что выходные значения yi будут содержать требуемый результат. Приступим к написанию своей реализации, а для простоты процесса в качестве бинарного оператора применим сложение. Поскольку этот пример приводится чисто для иллюстрации, то ядро будет запускаться на 1024 нитях. Давайте выпишем заголовок и опишем наше ядро: import pycuda.autoinit import pycuda.driver as drv import numpy as np from pycuda import gpuarray from pycuda.compiler import SourceModule from time import time naive_ker = SourceModule(""" __global__ void naive_prefix(double *vec, double *out) { __shared__ double sum_buf[1024]; int tid = threadIdx.x; sum_buf[tid] = vec[tid];
Теперь посмотрим на то, что у нас получилось. Представим наши входные элементы как массив из double в памяти GPU, т. е. double * vec, а выходной массив – как double * out. Объявим массив в разделяемой памяти sum_array, который
84
Ядра, нити, блоки и сетки
будем использовать для вычисления результата. Теперь взглянем на реализацию самого алгоритма: int iter = 1; for (int i=0; i < 10; i++) { __syncthreads(); if (tid >= iter ) { sum_buf[tid] = sum_buf[tid] + sum_buf[tid - iter]; } iter *= 2; } __syncthreads();
Конечно, здесь нет никакого parfor, который идет неявно через переменную tid, обозначающую номер нити. Благодаря проделанным действиям мы смогли избавиться от использования log2, начав со значения 1 и умножая его на 2 на каждую итерацию по i. (Обратите внимание, что если мы хотим сделать это более аккуратно, то можем заменить умножение на побитовый сдвиг.) Мы ограничили число итераций по i 10 , так как 210 = 1024. Теперь мы можем завершить наше ядро следующим образом: __syncthreads(); out[tid] = sum_buf[tid]; __syncthreads(); } """) naive_gpu = naive_ker.get_function("naive_prefix")
Посмотрим на наш код, следующий за ядром: if __name__ == '__main__': testvec = np.random.randn(1024).astype(np.float64) testvec_gpu = gpuarray.to_gpu(testvec) outvec_gpu = gpuarray.empty_like(testvec_gpu) naive_gpu( testvec_gpu , outvec_gpu, block=(1024,1,1), grid=(1,1,1)) total_sum = sum( testvec) total_sum_gpu = outvec_gpu[-1].get() print "Does our kernel work correctly? : {}".format(np.allclose(total_sum_gpu , total_sum) )
Мы используем только последнюю сумму из полученного результата, которую можно получить через outvec_gpu[-1].get(), поскольку индекс «–1» в Python дает нам последний элемент. Это будет сумма всех элементов из vec. (Данный пример содержится в файле naïve_prefix.py в репозитории в GitHub.) По своей природе алгоритм параллельной префиксной суммы должен выполняться на n нитях, соответствующих массиву из n элементов, где n является степенью двух. Однако мы можем расширить этот алгоритм до любого размера, не являющегося степенью двух, считая, что у нашего оператора есть нейтральный элемент, т. е. такое значение e, что для
Алгоритм параллельной префиксной суммы 85 любого x всегда справедливо e ⊕ x = x ⊕ e = x. В случае оператора сложения таким элементом является 0; в случае оператора умножения – 1. Тогда все, что нам нужно, – это дополнить массив достаточным числом значений e, чтобы получить размер, являющийся степенью двух.
Исключающая префиксная сумма и включающая префиксная сумма Давайте на мгновение остановимся и сделаем важное замечание. До сих пор мы рассматривали получение на вход элементов вида – x0, …, xn–1 и на выход – x0, x0 ⊕ x1, x0 ⊕ … ⊕ xn–1. Алгоритмы префиксной суммы, которые дают такой выход, называются включающими (inclusive prefix sum). Для каждого индекса элемент с данным индексом входит в соответствующую сумму по нему в выходном массиве. Это отличается от исключающей префиксной суммы (exclusive prefix sum). Алгоритм исключающей префиксной суммы отличается тем, что он так же берет на вход массив из n значений x0, …, xn–1 и на выход подает массив из n значений e, x0, x0 ⊕ x1, …, x0 ⊕ … ⊕ xn–2. Это важно, поскольку некоторые эффективные варианты алгоритма префиксной суммы по своей природе являются исключающими. Один из таких вариантов будет рассмотрен в следующем подразделе. Обратите внимание, что исключающий алгоритм дает практически тот же самый массив значений, только он сдвинут на одну позицию вправо, и окончательное значение отбрасывается. Это значит, что мы можем тривиально получить один и тот же результат от каждого алгоритма, при условии что у нас есть копия x0, …, xn–1.
Эффективный алгоритм параллельной префиксной суммы Прежде чем мы продолжим работу с новым алгоритмом, давайте взглянем на «наивный» алгоритм с двух точек зрения. В идеальном случае вычислительное время имеет сложность O(log n), но это только если мы имеем достаточное число процессоров для входного массива. Когда число элементов n заметно больше числа процессоров, мы получаем алгоритм со сложностью O(n log n). Давайте введем новое понятие по отношению к нашему бинарному оператору ⊕ – работа, выполненная параллельным алгоритмом, – это число вызовов данного оператора всеми нитями за все время выполнения. Аналогично размах (span) – это количество вызовов, которое нить делает за время работы ядра. Размах всего ядра наибольший среди всех нитей, что сообщает нам время выполнения. Мы хотим уменьшить объем работы, выполняемой алгоритмом по всем нитям, а не просто размах. В случае «наивного» алгоритма префиксной суммы допускается дополнительная работа, когда не хватает процессоров. Она просто распределяется на имеющиеся процессоры. Представим новый алгоритм, который позволяет уменьшить объем работы и поэтому более подходит для ограниченного числа процессоров. Он состо-
86
Ядра, нити, блоки и сетки
ит из двух отдельных частей – прохода вверх (upsweep, или редуцирование) и прохода вниз (downsweep). Следует заметить, что это алгоритм исключающей префиксной суммы. Проход вверх похож на обычную операцию редуцирования (т. е. нахождения всей суммы), за исключением того, что мы сохраняем частичные суммы, которые нужны для получения конечного результата. Проход вниз на основании этих частичных сумм выводит окончательный результат. Давайте взглянем на псевдокод, начиная с прохода вверх. (В следующем подразделе мы рассмотрим реализацию на основе псевдокода).
Эффективный алгоритм параллельной префиксной суммы (проход вверх) Ниже приводится псевдокод для прохода вверх (обратите внимание на parfor по переменной j, что означает, что данный блок кода может быть распараллелен по нитям, задаваемым j): input: x0, ..., xn–1 initialize: for i = 0 to n – 1: yi := xi begin: for k=0 to log2(n) - 1: parfor j=0 to n ‑ 1: if j is divisible by 2k+1: yj+2 –1 = yj+2 –1 ⊕ yj +2 else: continue end output: y0, ..., yn–1 k+1
k
k+1
–1
Эффективный алгоритм параллельной префиксной суммы (проход вниз) Теперь давайте рассмотрим проход вниз, который будет работать на результатах прохода вверх: input: x0, ..., xn–1 initialize: for i = 0 to n – 2: yi := xi yn–1 := 0 begin: for k = log2(n) – 1 to 0: parfor j = 0 to n – 1: if j is divisible by 2k+1: temp := yj+2 –1 yj+2 –1 := yj+2 –1 yj+2 -1 := yj+2 –1 ⊕ temp k
k
k+1
k+1
k+1
Алгоритм параллельной префиксной суммы 87 else: continue end output: y0 , y1 , ..., yn–1
Эффективный алгоритм параллельной префиксной суммы (реализация) В заключение данной главы мы напишем реализацию описанного алгоритма, который сможет работать с массивами произвольной длины, бóльшей 1024. Это значит, что алгоритм будет работать с сетками и блоками, и, соответственно, нам понадобится хост для синхронизации. Также нам придется реализовать два различных ядра для проходов вверх и вниз, которые будут соответствовать parfor в обоих проходах, и функции на Python, которые выступят в качестве внешнего цикла for для этих проходов. Давайте начнем с ядра для прохода вверх. Поскольку мы будем итеративно запускать это ядро с хоста много раз, то нам нужен параметр, задающий текущую итерацию (k). Мы также собираемся использовать два массива для избежания состояния гонки – x (для текущей итерации) и x_old (для предыдущей итерации). Мы объявим наше ядро следующим образом: up_ker = SourceModule(""" __global__ void up_ker(double *x, double *x_old, int k) {
Теперь зададим переменную tid, которая будет содержать номер текущей нити среди всех нитей блоков сетки. Мы используем тот же прием, что и в реализации игры «Жизнь»: int tid = blockIdx.x*blockDim.x + threadIdx.x;
Применим побитовые операторы сдвига языка С для получения по k значений 2k и 2k+1. Также мы зададим j равным tid, умноженным на _2k1, что поможет нам убрать оператор if j is divisible by 2k+1, имеющийся в псевдокоде, позволяя запускать ровно столько нитей, сколько нам на самом деле нужно: int _2k = 1 1:
Реализация последовательной сети 191 if len(self.network_mem) == 0: self.network_mem.append(gpuarray.empty(( self.max_batch_size, self.network_summary[-1][1]), dtype=np.float32)) self.network_mem.append(gpuarray.empty(( self.max_batch_size, self.network_summary[-1][2] ), dtype=np.float32 ) ) else: if len(self.network_mem) == 0: self.network_mem.append( gpuarray.empty( (self.network_summary[-1][1], ), dtype=np.float32 ) ) self.network_mem.append( gpuarray.empty(( self.network_summary[-1][2], ),dtype=np.float32 ) ) elif layer['type'] == 'softmax': if len(self.network) == 0: raise Exception("Ошибка! Softmax слой не может быть первым!") if self.network_summary[‑1][0] != 'dense': raise Exception("Ошибка! Нужен плотный слой перед слоем softmax!") num = self.network_summary[-1][2] self.network.append(SoftmaxLayer(num=num)) self.network_summary.append(('softmax', num, num)) if self.max_batch_size > 1: self.network_mem.append(gpuarray.empty(( self.max_batch_size,self.network_summary[-1][2]), dtype=np.float32)) else: self.network_mem.append( gpuarray.empty((self.network_summary[-1][2],), dtype=np.float32))
Реализация методов вывода Теперь мы добавим два метода для вывода к нашему классу SequentialNetwork, т. е. для предсказания выхода по заданным входным значениям. Первый метод мы назовем просто predict, и именно он будет применяться пользователем. В процессе тренировки нам нужно будет делать предсказания, основанные на частичном результате, только от нескольких слоев, для чего потребуется еще один метод – partial_predict. Давайте начнем с реализации predict. Он примет на вход два значения – набор значений в виде одно- или двумерного массива NumPy и, возможно, заданный пользователем поток CUDA. Осуществим проверку и проведем форматирование входных данных (называемых здесь х), помня при этом, что они будут храниться по строкам: def predict(self, x, stream=None): if stream is None:
192
Реализация глубокой нейросети
stream = self.stream if type(x) != np.ndarray: temp = np.array(x, dtype = np.float32) x = temp if(x.size == self.network_mem[0].size): self.network_mem[0].set_async(x, stream=stream) else: if x.size > self.network_mem[0].size: raise Exception("Ошибка: batch size слишком велик для входа.") x0 = np.zeros((self.network_mem[0].size,), dtype=np.float32) x0[0:x.size] = x.ravel() self.network_mem[0].set_async(x0.reshape( self.network_mem[0].shape),stream=stream) if(len(x.shape) == 2): batch_size = x.shape[0] else: batch_size = 1
Теперь выполним шаг самого вывода. Нам нужно просто пройти по сети, вызывая eval_ для каждого слоя: for i in xrange(len(self.network)): self.network[i].eval_(x=self.network_mem[i], y= self.network_mem[i+1], batch_size=batch_size, stream=stream)
Возьмем окончательный результат нейросети из памяти GPU и вернем его пользователю. Если число значений в массиве данных х меньше, чем максимальный размер batch_size, то разрежем один массив на несколько маленьких перед возвращением: y = self.network_mem[-1].get_async(stream=stream) if len(y.shape) == 2: y = y[0:batch_size, :] return y
Завершив операции с первым методом, перейдем к partial_predict. Но для начала давайте обсудим используемый подход. В процессе тренировки мы вычисляем выходные значения для входного набора данных и затем смотрим, как небольшое изменение в виде добавления delta к каждому весу и смещению по очереди влияет на выходные значения. Для экономии времени мы можем вычислить выходные значения для каждого слоя и сохранить их. Далее вычислим заново выход только для того слоя, где мы изменили вес, а также для всех следующих за ним слоев. Вскоре мы более подробно познакомимся с этим подходом, но пока можем реализовать его следующим образом: def partial_predict(self, layer_index=None, w_t=None, b_t=None, partial_mem=None, stream=None, batch_size=None, delta=None): self.network[layer_index].eval_(x=self.network_mem[layer_index],
Реализация последовательной сети 193 y = partial_mem[layer_index+1], batch_size=batch_size, stream = stream, w_t=w_t, b_t=b_t, delta=delta) for i in xrange(layer_index+1, len(self.network)): self.network[i].eval_(x=partial_mem[i], y =partial_mem[i+1],batch_size=batch_size, stream = stream)
Градиентный спуск Теперь напишем полную реализацию метода тренировки для нашей нейросети в виде стохастического градиентного спуска (batchstochastic gradient de scent, BSGD). Давайте разберем, что значит данное сочетание, слово за словом. Термин batch означает, что алгоритм тренировки работает на блоке/группе данных, а не со всем набором данных сразу, в то время как термин стохастический (stochastic) говорит о том, что такой блок выбирается случайным образом. Градиентный (gradient) означает, что мы будем использовать понятие «градиент» из математического анализа, т. е. набора производных для каждого веса и смещения от функции потери. И наконец, спуск (descent) – это наша попытка уменьшить функцию потери, для чего мы итеративно совершаем небольшие изменения над весами и смещениями путем вычитания градиента. Как мы помним из теории математического анализа, градиент в точке всегда показывает направление наибольшего роста, в то время как противоположное – это направление скорейшего убывания. Поскольку мы хотим уменьшать функцию, то вычитаем градиент.
Мы реализуем BSGD как метод bsgd у класса SequentialNetwork. Давайте один за другим разберем входные параметры bsgd: training – это двумерный массив NumPy с данными; labels – это выходной слой для нашей нейросети для каждого элемента входных данных; delta обозначает, насколько нужно увеличить вес для вычисления производных; max_streams задает максимальное число потоков CUDA, на которых будут происходить вычисления; batch_size задает, насколько большими должны быть блоки/группы (batch) данных, на которых мы будем вычислять функцию потери при каждом изменении весов; epochs задает, какое количество раз мы будем «перетасовывать» текущий набор данных, разбивая его на набор блоков/групп, и выполнять на них BSGD; training_rate задает скорость, с которой мы будем обновлять наши веса и смещения. Как обычно, начнем с проверки входных данных и преобразования типов, создания списка потоков CUDA, так же как и списка Python. Затем выделим необходимую память GPU, сохранив ее в другом списке: def bsgd(self, training=None, labels=None, delta=None, max_streams = None, batch_size = None, epochs = 1,
194
Реализация глубокой нейросети
training_rate=0.01): training_rate = np.float32(training_rate) training = np.float32(training) labels = np.float32(labels) if( training.shape[0] != labels.shape[0] ): raise Exception("Число значений для тренировки должно совпадать с чилом меток!") if max_streams is None: max_streams = self.max_streams if epochs is None: epochs = self.epochs if delta is None: delta = self.delta streams = [] bgd_mem = [] # создаем потоки, необходимые для тренировки for _ in xrange(max_streams): streams.append(drv.Stream()) bgd_mem.append([]) # выделяем память для каждого потока for i in xrange(len(bgd_mem)): for mem_bank in self.network_mem: bgd_mem[i].append( gpuarray.empty_like(mem_bank) )
Теперь мы можем начать обучение. Выполним одну итерацию BSGD для каждой epoch, применяя при этом случайную перестановку всего набора данных для каждой epoch. Мы выведем некоторую информацию на терминал так, чтобы пользователь видел, как идет процесс обучения: num_points = training.shape[0] if batch_size is None: batch_size = self.max_batch_size index = range(training.shape[0]) for k in xrange(epochs): print '---------------------------------------------------------' print Начинаем тренировку для эпохи: %s' % k print 'Batch size: %s , Общее число значений для тренировки: %s' % (batch_size, num_points) print '---------------------------------------------------------' all_grad = [] np.random.shuffle(index)
Сейчас мы напишем цикл, в котором будем перебирать все группы в «перестановленном» наборе данных. Для начала вычислим энтропию для текущей группы и напечатаем ее. Если пользователь увидит уменьшение энтропии, то он поймет, что градиентный поиск работает: for r in xrange(int(np.floor(training.shape[0]/batch_size))): batch_index = index[r*batch_size:(r+1)*batch_size]
Реализация последовательной сети 195 batch_training = training[batch_index, :] batch_labels = labels[batch_index, :] batch_predictions = self.predict(batch_training) cur_entropy = cross_entropy(predictions=batch_predictions, ground_truth=batch_labels) print Энтропия: %s' % cur_entropy
Пройдемся по каждому плотному слою в нашей нейросети, вычисляя градиент для всех весов и смещений. Сохраним все эти производные по весам и смещениям в плоских (одномерных) массивах, которые будут соответствовать w_t и b_t в наших ядрах CUDA, которые также являются плоскими. Поскольку у нас есть несколько потоков для обработки данных для различных весов, то мы будем использовать контейнер Queue (очередь) из Python для хранения наборов весов и смещений, которые должны быть обработаны для этой группы. Для следующего свободного потока мы просто снимаем значения с конца очереди (будем хранить их как списки, где первый элемент задает, является это весом или смещением): for i in xrange(len(self.network)): if self.network_summary[i][0] != 'dense': continue all_weights = Queue() grad_w = np.zeros((self.network[i].weights.size,), dtype=np.float32) grad_b = np.zeros((self.network[i].b.size,), dtype=np.float32) for w in xrange( self.network[i].weights.size ): all_weights.put( ('w', np.int32(w) ) ) for b in xrange( self.network[i].b.size ): all_weights.put(('b', np.int32(b) ) )
Нам необходимо обойти каждые вес и смещение, что можно сделать при помощи цикла while, проверяющего, пуст ли объект queue. Также мы создадим еще одну очередь, stream_weights, которая поможет нам организовать веса и смещения, что уже были обработаны потоками. После задания входных наборов весов и смещений мы можем использовать метод partial_predict, используя текущий поток и соответствующие массивы в памяти GPU: while not all_weights.empty(): stream_weights = Queue() for j in xrange(max_streams): if all_weights.empty(): break wb = all_weights.get() if wb[0] == 'w': w_t = wb[1] b_t = None elif wb[0] == 'b': b_t = wb[1] w_t = None
196
Реализация глубокой нейросети
stream_weights.put( wb ) self.partial_predict(layer_index=i, w_t=w_t, b_t=b_t, partial_mem=bgd_mem[j], stream=streams[j], batch_size=batch_size, delta=delta) Обратите внимание, что мы уже вызвали predict для этого блока данных для вычисления энтропии. Поэтому сейчас вызовем partial_predict (для этого блока), при условии что обязуемся аккуратно обращаться с используемыми памятью и слоями.
Мы только вычислили предсказание (оценку) выхода для изменений небольшого набора весов и смещений. Теперь для каждого из них нужно вычислить энтропию и сохранить производные в плоских массивах: for j in xrange(max_streams): if stream_weights.empty(): break wb = stream_weights.get() w_predictions = bgd_mem[j][-1].get_async(stream=streams[j]) w_entropy = cross_entropy(predictions= w_predictions[ :batch_size,:], ground_truth=batch_labels) if wb[0] == 'w': w_t = wb[1] grad_w[w_t] = -(w_entropy - cur_entropy) / delta elif wb[0] == 'b': b_t = wb[1] grad_b[b_t] = -(w_entropy - cur_entropy) / delta
Мы завершили наш цикл while. После выхода из него мы знаем, что вычислили производные по всем весам и смещениям для заданного слоя. Прежде чем перейти к следующему слою, добавим полученные значения градиента для текущего набора весов и смещений в список all_grad. Также изменим форму плоского списка весов, приведя его обратно к исходному виду: all_grad.append([np.reshape(grad_w,self.network[i].weights.shape) , grad_b])
После того как мы завершили обработку каждого слоя, можем выполнить оптимизацию весов и смещений в нашей нейросети для этого блока данных. Обратите внимание, что если значение переменной training_rate меньше 1, то это уменьшит изменение весов: for i in xrange(len(self.network)): if self.network_summary[i][0] == 'dense': new_weights = self.network[i].weights.get() new_weights += training_rate*all_grad[i][0] new_bias = self.network[i].b.get() new_bias += training_rate*all_grad[i][1] self.network[i].weights.set(new_weights) self.network[i].b.set(new_bias)
Мы полностью реализовали (очень простую!) DNN на GPU.
Данные Iris 197
Подготовка и нормализация данных Прежде чем приступить к обучению и тестированию нашей новой нейросети, давайте вернемся немного назад и поговорим о подготовке (conditioning) и нормализации (normalizing) данных. Нейросети очень чувствительны к численным ошибкам, особенно когда велик масштаб изменения данных. Для борьбы с этим можно использовать подготовку данных. Это значит, что мы находим по нашим данным среднее значение и среднеквадратичное отклонение и затем вычитаем из данных среднее значение и делим на среднеквадратичное отклонение, прежде чем передадим данные в нейросеть для обучения или построения выводов. Этот метод называется нормализацией. Давайте напишем небольшую функцию на Python, которая будет это делать для нас: def condition_data(data, means=None, stds=None): if means is None: means = np.mean(data, axis=0) if stds is None: stds = np.std(data, axis = 0) conditioned_data = data.copy() conditioned_data -= means conditioned_data /= stds return (conditioned_data, means, stds)
Данные iris Подготовим нашу глубокую нейросеть для выполнения ее первой реальной задачи: классификации типов цветов на основе измерения их лепестков. Для этого мы будем работать с известным набором данных Iris. Этот набор данных доступен как текстовый файл в формате CSV (Comma Separated Value), где каждая строка содержит четыре значения (результаты измерения лепестков), за которыми идет тип цветка (здесь есть всего три класса – Irisetosa, Irisversicolor и Irisvirginica). Теперь мы построим небольшую глубокую нейросеть, которая будет классифицировать тип цветка на основе данного набора данных. Прежде чем приступить, скачайте набор Iris и сохраните его в вашем рабочем каталоге. Этот набор доступен из репозитория по машинному обучению UC Irvine и может быть найден по адресу: https://archive.ics.uci.edu/ml/machine-learning-databases/iris/iris. data.
Мы начнем с чтения данных из этого файла в соответствующие массивы, которые будем использовать для обучения и проверки нашей нейросети. Нам нужно выполнить самую главную функцию – перевести названия цветов в классы, которые сможет использовать наша нейросеть. Для этого заведем небольшой словарь, который будет содержать соответствующую метку для каждого класса. Также создадим несколько пустых списков для хранения данных для обучения и меток:
198
Реализация глубокой нейросети
if __name__ == '__main__': to_class = { 'Iris‑setosa' : [1,0,0] , 'Iris‑versicolor' : [0,1,0], 'Irisvirginica' : [0,0,1]} iris_data = [] iris_labels = []
Теперь прочтем данные из CSV-файла. Мы будем использовать функцию reader из модуля csv для Python, который ранее импортировали: with open('C:/Users/btuom/examples/9/iris.data', 'rb') as csvfile: csvreader = csv.reader(csvfile, delimiter=',') for row in csvreader: newrow = [] if len(row) != 5: break for i in range(4): newrow.append(row[i]) iris_data.append(newrow) iris_labels.append(to_class[row[4]])
Далее мы случайным образом перемешаем данные и используем две трети из них для обучения нейросети. Оставшаяся одна треть будет применяться для тестирования (валидации): iris_len = len(iris_data) shuffled_index = list(range(iris_len)) np.random.shuffle(shuffled_index) iris_data = np.float32(iris_data) iris_labels = np.float32(iris_labels) iris_data = iris_data[shuffled_index, :] iris_labels = iris_labels[shuffled_index,:] t_len = (2*iris_len) // 3 iris_train = iris_data[:t_len, :] label_train = iris_labels[:t_len, :] iris_test = iris_data[t_len:,:] label_test = iris_labels[t_len:, :]
Именно сейчас мы можем начать строить нашу глубокую нейросеть! Сперва давайте создадим объект SequentialNetwork. Мы зададим max_batch_size равным 32: sn = SequentialNetwork( max_batch_size=32 )
Теперь приступим к построению самой нейросети. Она будет состоять из четырех плотных слоев (два из которых – скрытые) и слоя мягкого максимума. Мы начнем увеличивать число нейронов в каждом слое до последнего, в котором только три выходных значения (по одному на каждый класс). Увеличение числа нейронов для каждого слоя позволит нам отслеживать некоторые тонкие особенности данных:
Данные Iris 199 sn.add_layer({'type' : 'dense', 'num_inputs' : 4, 'num_outputs' : 10, 'relu': True, 'sigmoid': False, 'weights' : None, 'bias' : None} ) sn.add_layer({'type' : 'dense', 'num_inputs' : 10, 'num_outputs' : 15, 'relu': True, 'sigmoid': False, 'weights': None, 'bias' : None} ) sn.add_layer({'type' : 'dense', 'num_inputs' : 15, 'num_outputs' : 20, 'relu': True, 'sigmoid': False, 'weights': None, 'bias' : None} ) sn.add_layer({'type' : 'dense', 'num_inputs' : 20, 'num_outputs' : 3, 'relu': True, 'sigmoid': False, 'weights': None , 'bias': None } ) sn.add_layer({'type' : 'softmax'})
Нормализуем наши данные и приступим к обучению при помощи метода BSGD, который только что реализовали. Мы будем проводить обучение с batch_ size, равным 16, max_streams, равным 10, с epochs, равным 100, с delta, равным 0,0001, и training_rate, равным 1, – это наиболее приемлемые параметры для практически любого современного GPU. Кроме того, мы замерим время обучения, поскольку оно может оказаться довольно длительным: ctrain, means, stds = condition_data(iris_train) t1 = time() sn.bsgd(training=ctrain, labels=label_train, batch_size=16, max_streams=20,epochs=100 , delta=0.0001, training_rate=1) training_time = time() - t1
Итак, наша нейросеть полностью обучена. Мы готовы начать процесс валидации! Давайте заведем переменную с именем hits, которая будет содержать число корректных классификаций. Нам также потребуется нормализовать данные для валидации. Мы будем определять класс по индексу, соответствующему элементу с наибольшим значением в нашей нейросети. Для проверки того, получили ли мы правильную классификацию, применим функцию argmax из NumPy: hits = 0 ctest, _, _ = condition_data(iris_test, means=means, stds=stds) for i in range(ctest.shape[0]): if np.argmax(sn.predict(ctest[i,:])) == np.argmax( label_test[i,:]): hits += 1
Теперь мы готовы проверить, насколько хорошо нейросеть работает. Давайте выведем точность и затраченное на обучение время: print 'Точность классификации: %s' % (float(hits ) / ctest.shape[0]) print 'Общее время на тренировку: %s' % training_time
Мы это сделали! Мы смогли реализовать DNN при помощи CUDA и Python! В общем случае мы можем ожидать точность предсказания в районе 80–97 % для данной задачи и время обучения в районе 10–2 мин на GPU класса Pascal. Код для этой главы доступен в файле deep_neural_network.py в соответствующем каталоге в репозитории.
200
Реализация глубокой нейросети
резюме Данную главу мы начали с определения искусственной нейросети и показали, как отдельные искусственные нейроны могут быть объединены в плотные слои, которые объединяются в глубокую нейросеть. Далее мы реализовали плотный слой на CUDA C и написали соответствующий класс на Python. Также мы включили код для добавления слоев типа ReLU и сигмоид к выходам плотного слоя. Мы увидели определение и цель использования слоя мягкого максимума, который применяется для решения задач по классификации, и полностью реализовали это на CUDA С и Python. Наконец, мы реализовали класс на Python, который позволил задействовать последовательную глубокую нейросеть, используя ранее введенные классы. Мы осуществили функцию перекрестной потери энтропии и затем применили ее при реализации градиентного спуска для обучения нашей нейросети. В заключение мы при ее помощи создали, обучили и проверили глубокую нейросеть на реальном наборе данных. Надеюсь, что у вас появилась полная уверенность в своих навыках по программированию на CUDA, ведь вы смогли написать свою собственную глубокую нейросеть на GPU! В следующих двух главах мы перейдем к гораздо более серьезным темам: вы узнаете, как можно создавать свои интерфейсы к уже откомпилированному коду на CUDA, а также познакомитесь с некоторыми техническими деталями GPU от NVIDIA.
вопросы 1. Допустим, вы написали свою DNN и после обучения получаете от нее просто мусор. В ходе проверки вы вдруг обнаруживаете, что все веса и смещения являются или слишком большими числами, или вообще NaN-ми. В чем может быть проблема? 2. Назовите потенциальную проблему при использовании небольшого training_rate. 3. Назовите потенциальную проблему при использовании большого training_ rate. 4. Пусть мы хотим обучить нейросеть, которая должна присваивать метки изображениям животных (скользкое, с мехом, красное, коричневое и т. п.). Должны ли мы использовать сигмоидный слой или слой мягкого максимума? 5. Допустим, мы хотим классифицировать изображение животного как кошку или собаку. Что мы должны использовать: сигмоид или мягкий максимум? 6. Если мы увеличим размер группы, то в результате этого появится больше или меньше изменений весов и смещений во время градиентного спуска?
Глава
10
Работа с компилированным кодом для GPU
На протяжении всей книги мы полагались на то, что библиотека PyCUDA обеспечит нам взаимодействие с кодом на CUDA C, используя JIT-компиляцию и линковку. Однако вы можете вспомнить, что иногда процесс компиляции может занимать заметное время. В главе 3 мы подробно рассмотрели, как процесс компиляции влияет на время работы. Если мы имеем дело с системой реального времени, то такие задержки недопустимы. Сейчас мы разберем, как можно использовать в Python заранее откомпилированный код для GPU. В частности, рассмотрим три способа, как это можно сделать. Во-первых, попробуем это выполнить, используя С-функцию на хосте, которая сама запустит ядро CUDA. Этот метод включает в себя вызов функции на стороне хоста при помощи стандартной библиотеки Python Ctypes. Во-вторых, мы можем откомпилировать наше ядро в так называемый PTXмодуль, который фактически является DLL, содержащей откомпилированный бинарный код для GPU. Загрузим этот файл при помощи PyCUDA и непосредственно запустим само ядро. Наконец, в конце главы мы посмотрим на то, как написать полноценный Ctype-интерфейс к CUDA Driver API. После этого сможем использовать подходящие функции из Driver API для загрузки нашего файла PTX и запуска ядра. В результате изучения данной главы вы узнаете, как: запускать откомпилированный код (для хоста) при помощи модуля Ctypes; использовать «обертку» на стороне хоста для CUDA C для запуска ядра из Python при помощи Ctypes; откомпилировать модуль на CUDA C в PTX-файл; загрузить PTX-файл в PyCUDA для запуска заранее откомпилированных ядер; написать ваш собственный интерфейс к CUDA Driver API.
202
Работа с компилированным кодом для GPU
запуск откомпилированного коДа при помощи CtyPEs Мы начнем с краткого обзора модуля Ctypes из стандартной библиотеки языка Python. Данный модуль используется для вызова функций из откомпилированных файлов .so (Linux) и .dll (Windows). Это позволит нам вырваться из мира чистого Python и взаимодействовать с библиотеками и кодом, написанными на компилируемых языках, в первую очередь С и С++. NVIDIA предоставляет такие откомпилированные библиотеки для взаимодействия с нашим устройством CUDA, так что мы можем обойти стороной PyCUDA и использовать Ctypes. Попробую на простом примере показать вам, как вызывать функцию printf прямо из Ctypes. Откройте IPython и наберите import ctypes. Итак, мы хотим увидеть, каким образом вызывается стандартная функция printf из Ctypes. Вопервых, нам нужно импортировать соответствующую библиотеку. В Linux загрузите библиотеку LibC, набрав libc = ctypes.CDLL('libc.so.6') (под Windows замените 'libc.so.6' на 'msvcrt.dll' ). Теперь мы можем напрямую вызвать printf из IPython, просто набрав libc.printf("Hello from ctypes!\n"). Попробуйте сами! Сейчас предлагаю выполнить несколько другие действия. Наберите libc. printf("Pi is approximately %f.\n",3.14) из IPython – и вы получите ошибку. Это произошло потому, что значение 3,14 не было правильным образом переведено из типа float в Python в тип double в С, но это можно также сделать при помощи Ctypes, как показано ниже: libc.printf("Pi is approximately %f.\n", ctypes.c_double(3.14))
В результате мы получили именно тот выход, который и ожидали. Как и при запуске ядер CUDA из PyCUDA, нам нужно быть очень внимательными при приведении типов при использовании Ctypes. Всегда приводите входные значения для любой функции, которую вы вызываете при помощи Ctypes, из типов Python в соответствующие типы языка С (в Ctypes они начинаются с с_: c_float, c_double, c_char, c_int и т. д.).
Снова возвращаемся к вычислению множества Мандельброта Давайте обратимся к расчету множества Мандельброта, с которым столкнулись в главах 1 и 3. Для начала напишем полноценное ядро CUDA, которое вычислит множество Мандельброта для заданного набора параметров, и соответствующую функцию на стороне хоста, которую мы будем потом вызывать через Ctypes. Поместим эту функцию вместе с ядром в один файл на CUDA C и откомпилируем его в файл.dll или .so при помощи nvcc. Далее мы напишем код на Python, который запустит наш бинарный код и покажет множество Мандельброта. Сейчас мы применим знания Ctypes для запуска заранее откомпилированного ядра CUDA прямо из Python, без помощи PyCUDA. Для этого нам потребуется написать на стороне хоста функцию, которая и будет запускать ядро на
Запуск откомпилированного кода при помощи Ctypes 203 CUDA, откомпилированное в бинарный код, – в файл Dynamic Linked Library (DLL) под Windows или shared object (so) под Linux. Первым шагом будет написание кода на CUDA C. Откройте ваш любимый текстовый редактор. Мы начнем создание кода со стандартных операторов include: #include #include #include #include
Теперь перейдем непосредственно к самому написанию ядра. Обратите внимание на extern "C" в нашем коде, что нужно для того, чтобы мы могли вызывать необходимую функцию из бинарного файла: extern "C" __global__ void mandelbrot_ker(float * lattice, float * mandelbrot_graph, int max_iters, float upper_bound_squared, int lattice_size) {
Давайте подумаем о том, как это будет работать: мы собираемся использовать стандартный одномерный массив и для вещественных, и для мнимых компонент под названием lattice, имеющий длину lattice_size. Применим его для вычисления двумерного графика множества Мандельброта размером (lattice_size, lattice_size) и записи его в заранее выделенный массив mandelbrot_ graph. Мы зададим число итераций для проверки как max_iters, также указав квадрат максимальной верхней границы через upper_bound_squared (обратимся к причине использования квадрата далее). Запустим ядро на одномерной сетке с одномерными блоками, так что каждая нить будет соответствовать одной точке в результирующем изображении множества Мандельброта. Мы можем определить вещественные и мнимые значения для каждой точки следующим образом: int tid = blockIdx.x * blockDim.x + threadIdx.x; if ( tid < lattice_size*lattice_size ) { int i = tid % lattice_size; int j = lattice_size - 1 - (tid / lattice_size); float c_re = lattice[i]; float c_im = lattice[j];
Посмотрим, как это выглядит на практике. Запомните, что нам будет нужно использовать немногим больше нитей, чем необходимо, поэтому при помощи оператора if мы проверим, соответствует ли номер нити какой-либо точке в изображении. Также следует помнить, что выходной массив mandelbrot_graph будет храниться как одномерный массив, представляющий двумерное изображение, расположенное в памяти по строкам, и мы применим tid для индексации в этот массив. Мы будем использовать i и j как х и у координаты изображения на комплексной плоскости. Поскольку сетка – это набор вещественных
204
Работа с компилированным кодом для GPU
значений, отсортированных по возрастанию, нам понадобится изменить их порядок для получения соответствующих мнимых значений. Также обратите внимание, что мы хотим применить обычные числа типа float для представления комплексных значений, а не какую-нибудь структуру или объект. Поскольку у каждого комплексного числа есть вещественная и мнимая части, то для хранения комплексного числа, соответствующего узлу сетки, нам будут нужны два вещественных числа с плавающей точкой (c_re и c_im). Мы также создадим еще две переменные для проверки расхождения – z_re и z_im – и зададим начальное значение для точки, соответствующей текущей нити, равное 1, прежде чем начнем проверять ее на расхождение: float z_re = 0.0f; float z_im = 0.0f; mandelbrot_graph[tid] = 1;
Теперь необходимо выполнить проверку на расхождение. Если есть расхождение после max_iters итераций, то мы задаем значение для этой точки, равное 0. В противном случае оставляем его равным 1: for (int k = 0; k < max_iters; k++) { float temp; temp = z_re*z_re - z_im*z_im + c_re; z_im = 2*z_re*z_im + c_im; z_re = temp; if ( (z_re*z_re + z_im*z_im) > upper_bound_squared ) { mandelbrot_graph[tid] = 0; break; } }
Перед дальнейшими действиями давайте обсудим полученный фрагмент кода. Вспомним, что каждая итерация вычисления множества Мандельброта вычисляется при помощи комплексного умножения и сложения – z_new = z*z + c. Поскольку мы не используем класс для работы с комплексными числами, то нам нужно вычислить вещественную и мнимую части z. Также нам необходимо рассчитать модуль числа и проверить, не превышает ли он заданное значение. Помните, что модуль комплексного числа c = x + iy вычисляется как Мы можем выиграть немного времени за счет того, что заранее вычислим квадрат верхнего предела, чтобы использовать его для сравнения, избавившись тем самым от нахождения квадратного корня. Мы фактически завершили написание ядра. Теперь можно закрыть оператор if и вернуться из ядра, и…. почти готово: } return; }
Запуск откомпилированного кода при помощи Ctypes 205 Однако мы еще не все процессы выполнили. Нам осталось написать функцию для хоста с описанием extern "C" для Linux и extern "C" __declspec(dllexport) для Windows (в отличие от откомпилированного ядра CUDA это необходимо для того, чтобы мы потом смогли обратиться к данной функции при помощи Ctypes). Передаваемые в эту функцию параметры полностью соответствуют параметрам ядра, за исключением того, что они будут храниться на хосте: extern "C" __declspec(dllexport) void launch_mandelbrot( float * lattice, float * mandelbrot_graph, int max_iters, float upper_bound, int lattice_size) {
Нашей первой задачей будет выделение достаточной памяти для хранения сетки и выходных данных на GPU при помощи cudaMalloc. После чего скопируем данные в эту память при помощи cudaMemcpy: int num_bytes_lattice = sizeof(float) * lattice_size; int num_bytes_graph = sizeof(float)* lattice_size*lattice_size; float * d_lattice; float * d_mandelbrot_graph; cudaMalloc((float **) &d_lattice, num_bytes_lattice); cudaMalloc((float **) &d_mandelbrot_graph, num_bytes_graph); cudaMemcpy(d_lattice, lattice, num_bytes_lattice, cudaMemcpyHostToDevice);
Как и для многих других, мы запустим эти ядра на наборе одномерных блоков из 32 нитей над одномерной сеткой. Округлим вверх число точек, которые нужно проверить, деленное на 32, для определения размера сетки: int grid_size = (int) ceil( ( (double) lattice_size*lattice_size ) / ( (double) 32 ) );
Мы готовы запустить наше ядро при помощи традиционных для CUDA C тройных угловых скобок для задания размера сетки и блока. Обратите внимание на возведение в квадрат верхней границы перед передачей: mandelbrot_ker > (d_lattice, d_mandelbrot_graph, max_iters, upper_bound*upper_bound, lattice_size);
Теперь нам просто нужно скопировать выходные значения в память хоста и вызвать функцию cudaFree для выделенной памяти на устройстве. После этого мы можем выйти из функции: cudaMemcpy(mandelbrot_graph, d_mandelbrot_graph, num_bytes_graph, cudaMemcpyDeviceToHost); cudaFree(d_lattice); cudaFree(d_mandelbrot_graph);
Итак, мы завершили работу с кодом на CUDA C. Пожалуйста, сохраните его в файл mandelbrot.cu, чтобы мы могли перейти к следующему шагу. Вы также можете скачать этот файл по адресу: https://github.com/btuomanen/handsongpuprogramming/blob/master/10/mandelbrot.cu.
206
Работа с компилированным кодом для GPU
Компиляция кода и его взаимодействие с Ctypes Давайте откомпилируем код, который мы только что написали, в бинарный файл .dll или .so. Это абсолютно безболезненно: если вы работаете под Linux, то наберите следующую строку для компиляции файла в mandelbrot.so: nvcc -Xcompiler -fPIC -shared -o mandelbrot.so mandelbrot.cu
Если вы работаете под Windows, то для компиляции в файл mandelbrot.dll выполните следующую команду: nvcc -shared -o mandelbrot.dll mandelbrot.cu
Теперь мы можем написать интерфейс на Python. Начнем с необходимых операторов импорта, полностью исключив PyCUDA, используя только Ctypes. Для облегчения работы мы импортируем все классы и функции из Ctypes прямо в пространство имен Python: from __future__ import division from time import time import matplotlib from matplotlib import pyplot as plt import numpy as np from ctypes import *
Зададим интерфейс для функции launch_mandelbrot при помощи Ctypes. Вопервых, нам нужно загрузить откомпилированный файл .dll и .so (пользователям Linux придется заменить имя файла на mandelbrot.so): mandel_dll = CDLL('./mandelbrot.dll')
Ссылку на функцию launch_mandelbrot можно получить из библиотеки: mandel_c = mandel_dll.launch_mandelbrot
Перед тем как мы вызвать функцию при помощи Ctypes, нам необходимо сообщить Ctypes о том, какие входные значения данная функция ожидает. Давайте напомним, что для функции launch_mandelbrot входные значения имеют типы float *, float *, int, float и int. Мы можем задать эти типы при помощи параметра argtypes, используя для этого соответствующие типы из Ctypes (c_float, c_int) и класс POINTER: mandel_c.argtypes = [POINTER(c_float), POINTER(c_float), c_int, c_float, c_int]
Выполним следующий шаг, а именно напишем функцию на Python, которая вызовет данную функцию для нас. Мы зададим ширину и высоту выходного изображения через параметр breadth, а также минимальные и максимальные значения для вещественной и мнимой частей на комплексной плоскости. Нам нужно задать максимальное число итераций и верхнюю границу для сравнения: def mandelbrot(breadth, low, high, max_iters, upper_bound):
Теперь создадим нашу сетку при помощи функции linspace из NumPy: lattice = np.linspace(low, high, breadth, dtype=np.float32)
Запуск откомпилированного кода при помощи Ctypes 207 Вспомним, что нам требуется передать заранее выделенный массив из чисел с плавающей точкой в launch_mandelbrot для получения выходного графика. Мы можем сделать это при помощи команды empty из NumPy, задав подходящую форму и размер массива, что фактически будет аналогом malloc: out = np.empty(shape=(lattice.size,lattice.size), dtype=np.float32)
Итак, мы готовы вычислить наше множество Мандельброта. Обратите внимание, что можно передать массивы из NumPy, как и в С, используя метод ctypes.data_as с соответствующим типом. После чего вернем результаты, т. е. график множества Мандельброта, в виде двумерного массива NumPy: mandel_c(lattice.ctypes.data_as(POINTER(c_float)), out.ctypes.data_as(POINTER(c_float)), c_int(max_iters), c_float(upper_bound), c_int(lattice.size) ) return out
Теперь напишем главную функцию для вычисления и построения графика множества Мандельброта вместе с измерением затраченного на расчет времени: if __name__ == '__main__': t1 = time() mandel = mandelbrot(512,-2,2,256, 2) t2 = time() mandel_time = t2 - t1 print 'Потребовалось %s для вычисления множества Мандельброта.' % mandel_time plt.figure(1) plt.imshow(mandel, extent=(-2, 2, -2, 2)) plt.show()
Запустим полученную программу. Вы получите изображение, выглядящее в точности как то, что мы видели в главах 1 и 3:
208
Работа с компилированным кодом для GPU
Код для указанного примера на Python также доступен в репозитории GitHub как файл mandelbrot_ctypes.py.
компиляция и запуск PtX-коДа Мы только что с вами рассмотрели, как вызывать функции на С при помощи Ctypes. В некоторых случаях это может показаться не очень удобным, так как наш бинарный файл должен содержать код для хоста вместе с откомпилированным кодом для GPU. Можем ли мы использовать чистый откомпилированный код для GPU и запускать его, не используя для этого каждый раз «обертку» на С? К счастью, такое возможно. NVCC компилирует код на CUDA C в PTX (Parallel Thread Execution), который является интерпретируемым псевдоассемблером, совместимым с различными архитектурами GPU от NVIDIA. Когда вы компилируете программу, содержащую ядро CUDA, при помощи NVCC в выполнимый файл .exe, .dll, .so или .elf, то внутри этого файла будет код на PTX для данного ядра1. Мы также можем откомпилировать код прямо в файл PTX, который в результате будет содержать откомпилированное ядро из нашего файла .cu. К счастью, PyCUDA содержит интерфейс для загрузки ядер CUDA непосредственно из PTX-файлов, освобождая нас от необходимости вручную выполнять JIT-компиляцию, но позволяя использовать все возможности CUDA. Давайте откомпилируем код для расчета множества Мандельброта, который мы только что написали, в файл PTX. При этом не нужно вносить в него никаких изменений, достаточно всего лишь выполнить следующую команду (и для Windows, и для Linux): nvcc -ptx -o mandelbrot.ptx mandelbrot.cu
Затем изменим программу на Python из последнего раздела для использования кода на PTX. Мы уберем из импорта Ctypes, а вместо этого добавим соответствующие модули из PyCUDA: from __future__ import division from time import time import matplotlib from matplotlib import pyplot as plt import numpy as np import pycuda from pycuda import gpuarray import pycuda.autoinit
Загрузим PTX-файл при помощи функции module_from_file из PyCUDA: mandel_mod = pycuda.driver.module_from_file('./mandelbrot.ptx')
1
Это не совсем верно – можно задать опцией, что требуется откомпилировать ядро прямо в систему команд конкретного GPU. – Прим. перев.
Написание «оберток» для CUDA Driver API 209 Мы можем получить ссылку на наше ядро при помощи get_function так же, как уже делали, когда использовали SourceModule: mandel_ker = mandel_mod.get_function('mandelbrot_ker')
Теперь мы можем переписать функцию для расчета множества Мандельброта, используя ядро вместе с соответствующими объектами класса gpuarray и конструкциями приведения типа (мы не будем разбирать данный фрагмент кода строка за строкой, поскольку сейчас это уже должно быть и так очевидно): def mandelbrot(breadth, low, high, max_iters, upper_bound): lattice = gpuarray.to_gpu(np.linspace(low, high, breadth, dtype=np.float32) out_gpu = gpuarray.empty(shape=(lattice.size,lattice.size), dtype=np.float32) gridsize = int(np.ceil(lattice.size**2 / 32)) mandel_ker(lattice, out_gpu, np.int32(256), np.float32(upper_bound**2), np.int32(lattice.size), grid=(gridsize, 1, 1), block=(32,1,1)) out = out_gpu.get() return out
Функция main будет такой же, как и в предыдущем разделе: if __name__ == '__main__': t1 = time() mandel = mandelbrot(512,-2,2,256,2) t2 = time() mandel_time = t2 - t1 print 'Потребовалось %s секунд для вычисления множества Мандельброта.' % mandel_time plt.figure(1) plt.imshow(mandel, extent=(-2, 2, -2, 2)) plt.show()
Попробуйте запустить этот код, для того чтобы убедиться в верности полученного результата. Вы также можете заметить некоторое улучшение по времени по сравнению с версией, использующей Ctypes. Этот код доступен на GitHub в виде файла mandelbrot_ptx.py в каталоге 10 в репозитории.
написание «оберток» Для CUDA DrivEr APi Сейчас мы рассмотрим, как можно написать свои «обертки» для некоторых бинарных библиотечных функций из CUDA при помощи Ctypes. В частности, будем писать «обертки» для CUDA Driver API, что позволит нам выполнять все необходимые операции, требуемые для использования GPU, включая его инициализацию, выделение/копирование/освобождение памяти, запуск ядер и создание/синхронизацию/удаление контекста. Эта важная информация по-
210
Работа с компилированным кодом для GPU
зволит нам использовать GPU без помощи PyCUDA и даже без написания неуклюжих «оберток» на С. Мы создадим небольшой модуль, который будет действовать как библиотека для CUDA Driver API. Давайте обсудим, что это значит. CUDA Driver API отличается и больше уходит в технические детали, чем CUDA Runtime API. Последний – это тот самый API, с которым мы работали, когда писали код на CUDA C. Driver API предназначен для работы с обычным компилятором С/С++, а не NVCC, и содержит ряд отличий, таких как использование функции cuLaunchKernel вместе с . Это позволит нам напрямую обращаться к необходимым функциям, которые необходимы для запуска ядра из PTX-файла, при помощи Ctypes. Начнем писать наш модуль с импорта всего из Ctypes в пространство имен модуля, а также импорта модуля sys. Сделаем модуль пригодным для использования как под Windows, так и под Linux за счет загрузки правильного библиотечного файла (либо nvcuda.dll, либо libcuda.so), проверив тип операционной системы через sys.platform, как показано ниже: from ctypes import * import sys if 'linux' in sys.platform: cuda = CDLL('libcuda.so') elif 'win' in sys.platform: cuda = CDLL('nvcuda.dll')
Мы успешно загрузили CUDA Driver API и можем заняться написанием «оберток» для функций, необходимых для использования GPU. По мере продвижения будем изучать прототипы всех функций, что необходимо, если вы пишете «обертки» на Ctypes. Советую читателю ознакомиться со всеми используемыми функциями в официальной документации NVIDIA по адресу: https://docs.nvidia.com/cuda/cuda-driverapi/.
Давайте выполним самую главную функцию при применении CUDA Driver API, cuInit, которая инициализирует Driver API. Она принимает на вход беззнаковое целое число, используемое для задания флагов, и возвращает значение типа CUresult, что фактически также является целым числом. Мы можем написать следующую «обертку»: cuInit = cuda.cuInit cuInit.argtypes = [c_uint] cuInit.restype = int
Теперь перейдем к следующей функции, cuDeviceCount, которая сообщит нам, сколько CUDA-совместимых GPU установлено в системе. В качестве единственного входного значения она берет указатель на целое число, т. е. возвращает значение по ссылке. Все функции возвращают значение типа CUresult, что является стандартным типом для кодов ошибки в Driver API. Например, если
Написание «оберток» для CUDA Driver API 211 какая-то функция возвращает 0, то это означает результат CUDA_SUCCESS, в то время как ненулевое значение всегда говорит об ошибке или подразумевает предупреждение (warning): cuDeviceGetCount = cuda.cuDeviceGetCount cuDeviceGetCount.argtypes = [POINTER(c_int)] cuDeviceGetCount.restype = int
Напишем «обертку» для cuDeviceGet, которая возвращает дескриптор (handle) устройства по ссылке, содержащейся в первом входном значении. Он будет соответствовать номеру GPU, переданному как второй входной параметр. Первый входной параметр имеет тип CUdevice* и является, по сути, указателем на целое число: cuDeviceGet = cuda.cuDeviceGet cuDeviceGet.argtypes = [POINTER(c_int), c_int] cuDeviceGet.restype = int
Давайте вспомним, что каждая сессия CUDA требует, по крайней мере, один контекст CUDA, который можно рассматривать как аналог процесса на CPU. Поскольку он обрабатывается автоматически в Runtime API, здесь нам будет необходимо создать его для используемого устройства (применяя дескриптор устройства), прежде чем мы сможем это устройство использовать. А по завершении сессии CUDA мы должны его удалить. Контекст CUDA можно создать и при помощи функции cuCtxCreate. Посмотрим на ее прототип в документации: CUresult cuCtxCreate(CUcontext* pctx, unsigned int flags, CUdevice dev)
Конечно, возвращаемым значением является CUresult. Первый параметр – это указатель на тип CUcontext, который на самом деле является указателем на структуру, используемую внутри CUDA. Поскольку единственным нашим применением CUcontext из Python будет сохранение его и передача в другие функции, то мы можем трактовать CUcontext как тип void * в С, т. е. просто адрес в памяти. Так как фактически это указатель на CUcontext (который является сам указателем на внутреннюю структуру), то мы будем задавать его как void *, что в Ctypes обозначается c_void_p. Вторым входным значением является беззнаковое целое число, а последним – дескриптор устройства, для которого мы будем создавать контекст. Как помним, это просто целое число. Теперь мы готовы написать «обертку» для cuCtxCreate: cuCtxCreate = cuda.cuCtxCreate cuCtxCreate.argtypes = [c_void_p, c_uint, c_int] cuCtxCreate.restype = int Вы всегда можете использовать тип void* в С/С++ (c_void_p в Ctypes) для обозначения указателя на данные или переменную – даже на структуры или объекты, определение которых может быть недоступным.
212
Работа с компилированным кодом для GPU
Следующей функцией является cuModuleLoad, которая загрузит для нас PTXфайл. Первый входной аргумент – это ссылка на CUmodule (опять мы будем использовать тип c_void_p), а вторым аргументом является имя файла, который значится стандартной строкой в С, завершенной нулевым байтом, – т. е. char*, или в Ctypes c_char_p: cuModuleLoad = cuda.cuModuleLoad cuModuleLoad.argtypes = [c_void_p, c_char_p] cuModuleLoad.restype = int
Следующая функция используется для синхронизации всех запущенных операций в текущем контексте и называется cuCtxSynchronize (не принимает на вход никаких аргументов): cuCtxSynchronize = cuda.cuCtxSynchronize cuCtxSynchronize.argtypes = [] cuCtxSynchronize.restype = int
Теперь перейдем к функции, используемой для получения дескриптора ядра из загруженного модуля, чтобы можно было запустить его на GPU, что соответствует функции get_function в PyCUDA, с которой нам доводилось часто встречаться. Согласно документации, прототипом данной функции является CUresult cuModuleGetFunction (CUfunction* hfunc, CUmodule hmod, const char* name). Мы напишем следующую «обертку»: cuModuleGetFunction = cuda.cuModuleGetFunction cuModuleGetFunction.argtypes = [c_void_p, c_void_p, c_char_p ] cuModuleGetFunction.restype = int
Далее напишем «обертки» для стандартных операций с динамической памятью. Они необходимы, поскольку мы не будем использовать объекты gpuarray из PyCUDA. Эти «обертки» практически такие же, как и в Runtime API, с которыми мы работали ранее, т. е. cudaMalloc, cudaMemcpy и cudaFree: cuMemAlloc = cuda.cuMemAlloc cuMemAlloc.argtypes = [c_void_p, c_size_t] cuMemAlloc.restype = int cuMemcpyHtoD = cuda.cuMemcpyHtoD cuMemcpyHtoD.argtypes = [c_void_p, c_void_p, c_size_t] cuMemAlloc.restype = int cuMemcpyDtoH = cuda.cuMemcpyDtoH cuMemcpyDtoH.argtypes = [c_void_p, c_void_p, c_size_t] cuMemcpyDtoH.restype = int cuMemFree = cuda.cuMemFree cuMemFree.argtypes = [c_void_p] cuMemFree.restype = int
Сейчас мы создадим «обертку» для функции cuLaunchKernel. Конечно, это именно то, что позволит нам запускать ядра CUDA на GPU, при условии что мы уже проинициализировали CUDA Driver API, создали контекст, загрузили мо-
Написание «оберток» для CUDA Driver API 213 дуль, выделили память и настроили входные значения, выделив функцию ядра из загруженного модуля. Она гораздо сложнее, чем предыдущие функции, поэтому мы сначала посмотрим на ее прототип: CUresult cuLaunchKernel ( CUfunction f, unsigned int gridDimX, unsigned int gridDimY, unsigned int gridDimZ, unsigned int blockDimX, unsigned int blockDimY, unsigned int blockDimZ, unsigned int sharedMemBytes, CUstream hStream, void** kernelParams, void** extra )
Первым параметром является дескриптор ядра, которое мы хотим запустить, что можно представить в виде c_void_p. Шесть параметров gridDim и blockDim используются для задания размеров сетки и блоков. Беззнаковое целое число sharedMemBytes применяется для задания того, какое количество байтов разделяемой памяти будет выделено каждому блоку при запуске ядра. CUstream hstream – это необязательный параметр, необходимый для задания того, в каком потоке следует выполнять ядро. В случае его использования по умолчанию следует задать NULL(0), что мы можем представить как c_void_p в Ctypes. Наконец, kernelParams и extra используются для задания входных аргументов ядра; это довольно сложно, поэтому пока важно то, что мы можем представить их как c_void_p: cuLaunchKernel = cuda.cuLaunchKernel cuLaunchKernel.argtypes = [c_void_p, c_uint, c_uint, c_uint, c_uint, c_uint, c_uint, c_uint, c_void_p, c_void_p, c_void_p] cuLaunchKernel.restype = int
Теперь нам осталось написать «обертку» для последней функции, cuCtxDestroy. Мы используем ее в конце сессии CUDA для удаления контекста на GPU. Единственным входным значением является CUcontext, который мы представим как c_void_p: cuCtxDestroy = cuda.cuCtxDestroy cuCtxDestroy.argtypes = [c_void_p] cuCtxDestroy.restype = int
Давайте сохраним все это в файл cuda_driver.py. Мы завершили наш модуль для CUDA Driver API! Следующим, на что мы посмотрим, будет, как загрузить модуль PTX и запустить ядро, используя наш модуль и наш PTX для расчета множества Мандельброта. Этот пример также доступен на GitHub как файл cuda_driver.py в репозитории.
Использование CUDA Driver API Сейчас мы перепишем нашу небольшую программку по расчету множества Мандельброта для использования новой библиотеки. Давайте начнем с операторов импорта. Обратите внимание, как мы загружаем все наши «обертки» в текущее пространство имен:
214
Работа с компилированным кодом для GPU
from __future__ import division from time import time import matplotlib from matplotlib import pyplot as plt import numpy as np from cuda_driver import *
Мы поместим весь код для GPU в функцию mandelbrot, как делали ранее. Начнем с инициализации CUDA Driver API при помощи cuInit, а затем проверим, есть ли хоть одно подходящее GPU в системе, отбрасывая исключение в противном случае: def mandelbrot(breadth, low, high, max_iters, upper_bound): cuInit(0) cnt = c_int(0) cuDeviceGetCount(byref(cnt)) if cnt.value == 0: raise Exception('Не найдено GPU!')
Обратите внимание на использование здесь byref: в Ctypes это эквивалент оператора взятия адреса (&) в С. Мы можем вновь применить данный подход, помня о том, что дескриптор устройства и контекст CUDA будут представлены как c_int и c_void_p в Ctypes: cuDevice = c_int(0) cuDeviceGet(byref(cuDevice), 0) cuContext = c_void_p() cuCtxCreate(byref(cuContext), 0, cuDevice)
Загрузим наш модуль PTX, помня при этом о необходимости переведения имени файла в строку С при помощи c_char_p: cuModule = c_void_p() cuModuleLoad(byref(cuModule), c_char_p('./mandelbrot.ptx'))
Теперь зададим сетку точек на стороне хоста, так же как и массив NumPy, состоящий из 0 с именем graph, который будет использоваться для хранения результата на стороне хоста. Также мы выделим память GPU как для сетки, так и для полученного результата и скопируем сетку на GPU при помощи cuMemcpyHtoD: lattice = np.linspace(low, high, breadth, dtype=np.float32) lattice_c = lattice.ctypes.data_as(POINTER(c_float)) lattice_gpu = c_void_p(0) graph = np.zeros(shape=(lattice.size, lattice.size), dtype=np.float32) cuMemAlloc(byref(lattice_gpu), c_size_t(lattice.size*sizeof(c_float))) graph_gpu = c_void_p(0) cuMemAlloc(byref(graph_gpu), c_size_t(lattice.size**2 * sizeof(c_float))) cuMemcpyHtoD(lattice_gpu, lattice_c, c_size_t(lattice.size*sizeof(c_float)))
Написание «оберток» для CUDA Driver API 215 После всех проделанных операций мы получим дескриптор нашего ядра для расчета множества Мандельброта при помощи cuModuleGetFunction и зададим некоторые входные значения: mandel_ker = c_void_p(0) cuModuleGetFunction(byref(mandel_ker), cuModule, c_char_p('mandelbrot_ker')) max_iters = c_int(max_iters) upper_bound_squared = c_float(upper_bound**2) lattice_size = c_int(lattice.size)
Следующий шаг является более сложным. Прежде чем мы продолжим, нам необходимо понять, как именно передаются параметры в ядро CUDA при помощи cuLaunchKernel. Давайте сначала посмотрим, как это работает в CUDA C. Зададим входные параметры в kernelParams как массив из значений void*, которые являются указателями на те значения, что мы хотим передать в ядро. В случае нашего ядра для расчета множества Мандельброта они имеют вид: void * mandel_params [] = {&lattice_gpu, &graph_gpu, &max_iters, &upper_bound_squared, &lattice_size};
Посмотрим, как мы можем выразить это в Ctypes, что само по себе не является очевидным. Нам требуется поместить все входные параметры в список Python в надлежащем порядке: mandel_args0 = [lattice_gpu, graph_gpu, max_iters, upper_bound_squared, lattice_size ]
Далее нам нужны указатели на каждое из этих значений, приведенные к void*. Используем функцию addressof из Ctypes для получения адреса каждой Ctypes-переменной (что аналогично byref, только не привязано к конкретному типу) и переведем их в c_void_p. Сохраним эти значения в другом списке: mandel_args = [c_void_p(addressof(x)) for x in mandel_args0]
Затем используем Ctypes, но на этот раз для перевода списка Python в массив указателей void*, как показано ниже: mandel_params = (c_void_p * len(mandel_args))(*mandel_args)
Теперь можем задать размер нашей сетки, как делали это ранее, и запустить наше ядро с данным набором параметров при помощи cuLaunchKernel. После этого мы проведем синхронизацию контекста: gridsize = int(np.ceil(lattice.size**2 / 32)) cuLaunchKernel(mandel_ker, gridsize, 1, 1, 32, 1, 1, 10000, None, mandel_params, None) cuCtxSynchronize()
Скопируем данные из памяти GPU в массив NumPy при помощи функции cuMemcpyDtoH с параметром array.ctypes.data, который дает нам указатель язы-
216
Работа с компилированным кодом для GPU
ка С, позволяющий тем самым напрямую обратиться к массиву С как к блоку памяти. Переведем полученное значение в c_void_p при помощи функции приведения типа cast из Ctypes: cuMemcpyDtoH( cast(graph.ctypes.data, c_void_p), graph_gpu, c_size_t(lattice.size**2 *sizeof(c_float)))
Мы почти все сделали! Давайте освободим выделенную на GPU память и завершим нашу сессию на GPU, удалив текущий контекст. После этого вернем результаты в вызывающую функцию: cuMemFree(lattice_gpu) cuMemFree(graph_gpu) cuCtxDestroy(cuContext) return graph
Сейчас мы можем написать нашу функцию main, как и раньше: if __name__ == '__main__': t1 = time() mandel = mandelbrot(512,-2,2,256, 2) t2 = time() mandel_time = t2 - t1 print 'Потребовалось %s секунд для вычисления множества Мандельброта.' % mandel_time fig = plt.figure(1) plt.imshow(mandel, extent=(-2, 2, -2, 2)) plt.show()
Теперь запустите эту программу, для того чтобы убедиться, что она дает тот же результат, что и другие программы для расчета множества Мандельброта, которые мы уже писали. Поздравляю! Вы создали непосредственный интерфейс к низкоуровневому CUDA Driver API и успешно запустили с его помощью ядро! Эта программа доступна в файле mandelbrot_driver.py на GitHub в соответствующем данной главе каталоге в репозитории.
резюме Мы начали эту главу с краткого обзора библиотеки Ctypes, которая используется для непосредственного взаимодействия с компилированным бинарным кодом и, в частности, динамическими библиотеками, написанными на С/С++. Далее мы посмотрели, как написать «обертку» на С, которая запускает ядро, а затем использовали ее для непосредственного запуска нашего ядра из Python при помощи Ctypes. После мы узнали, как откомпилировать ядро CUDA в модуль PTX, который можно рассматривать как аналог .dll, но содержащий функции CUDA, и увидели, как загрузить PTX-файл, заранее запустив откомпилированное ядро при помощи PyCUDA. Наконец, мы написали набор «оберток» для CUDA Driver API при помощи Ctypes и увидели, как можно их использовать
Вопросы 217 для выполнения базовых операций на GPU, включая запуск заранее откомпилированного ядра из PTX-файла. Теперь перейдем к тому, что является наиболее низкоуровневой (и технической) главой книги, – главе 11 «Оптимизация быстродействия в CUDA». В ней вы узнаете о некоторых технических деталях NVIDIA GPU и о том, как это поможет повысить быстродействие наших приложений.
вопросы 1. Допустим, что вы используете nvcc для компиляции одного файла .cu, содержащего код для хоста и GPU, в exe-файл, а также в PTX-файл. Какой файл будет содержать функции для хоста и какой для GPU? 2. Почему нам нужно удалитьконтекст, если мы используем CUDA Driver API? 3. В начале этой главы мы увидели, как использовать Ctypes, и обратили внимание, что нам нужно перевести значение с плавающей точкой 3,14 в объект c_double из Ctypes, для того чтобы вызов printf корректно работал. Тем не менее в данной главе мы видели много работающих случаев, когда не использовалось приведение типа в Ctypes. Почему вы думаете, что printf является исключением? 4. Допустим, что вы хотите добавить в наш модуль для работы с CUDA Driver API поддержку потоков CUDA. Как бы вы представили поток в CUDA? 5. Почему мы использовали extern "C" для функции в mandelbrot.cu? 6. Снова посмотрите на mandelbrot_driver.py. Почему мы не используем функцию cuCtxSynchronize после выделения памяти и копирования хост/GPU, а лишь после единственного запуска ядра?
Глава
11 Оптимизация быстродействия в CUDA
В этой предпоследней главе мы рассмотрим некоторые продвинутые возможности CUDA для низкоуровневых оптимизаций быстродействия. Начнем с изучения динамического параллелизма, что позволит одним ядрам запускать и управлять другими ядрами на GPU, и увидим, как можно применить это для организации быстрой сортировки. Мы также поговорим про векторный доступ к памяти, который может быть использован для увеличения скорости доступа при работе с глобальной памятью GPU. Далее узнаем, как можно использовать атомарные операции в CUDA, позволяющие работать с общими данными из разных нитей, без необходимости применения их синхронизации, или мьютексы. Коснемся варпов (warp), которые являются фундаментальными блоками из 32 или меньшего числа нитей, где одни нити могут напрямую читать и писать переменные других, и совершим небольшое погружение в мир РТХ-ассемблера. Мы станем делать это, непосредственно вставляя команды PTX прямо в код на CUDA C, который будет вписан в код на Python! Наконец, мы соединим все эти элементы в одном завершающем примере, где они будут применены для написания сверхбыстрого ядра для сложения, и сравним его со сложением в PyCUDA. В результате изучения этой главы вы узнаете: о динамическом параллелизме в CUDA; о реализации быстрой сортировки на GPU при помощи динамического параллелизма; об использовании векторных типов для ускорения доступа к памяти устройства; об использовании потокобезопасных атомарных операций в CUDA; об основах РТХ-ассемблера; о том, как применять все эти понятия для написания оптимизированной версии ядра для нахождения суммы.
Динамический параллелизм 219
Динамический параллелизм Для начала мы обратимся к динамическому параллелизму, возможности CUDA, которая позволяет ядру запускать другие ядра и управлять ими без взаимодействия или ввода со стороны хоста. Благодаря этому многие элементы на стороне хоста становятся также доступными и на GPU, включая выделение/ освобождение памяти, копирование памяти в пределах устройства, синхронизацию в пределах контекста и потоки. Давайте начнем с простого примера. Мы создадим небольшое ядро и запустим его на N нитях, каждая из которых напечатает короткое сообщение на терминал, а затем рекурсивно запустит другое ядро на N – 1 нитей. Этот процесс будет продолжаться до тех пор, пока N не станет равным 1. (Конечно, кроме как для иллюстрации динамического параллелизма этот пример абсолютно бесполезен.) Рассмотрим операторы импорта в Python: from __future__ import division import numpy as np from pycuda.compiler import DynamicSourceModule import pycuda.autoinit
Обратите внимание, что нам нужно импортировать DynamicSourceModule, а не обычный SourceModule! Это связано с тем, что динамический параллелизм требует определенной конфигурации, заданной для компилятора. В остальном данный модуль ведет себя точно так же, как и SourceModule. Продолжим написание ядра: DynamicParallelismCode=''' __global__ void dynamic_hello_ker(int depth) { printf("Привет из нити %d, глубина рекурсии %d!\\n", threadIdx.x, depth); if (threadIdx.x == 0 && blockIdx.x == 0 && blockDim.x > 1) { printf("Запускаем новое ядро из глубины %d .\\n", depth); printf("-----------------------------------------\\n"); dynamic_hello_ker>(depth + 1); } }'''
Наиболее важным здесь является следующее: нам нужно быть внимательными, чтобы запустить всего один раз следующую итерацию при помощи правильного оператора if, который проверяет значения threadIdx и blockIdx. Если мы этого не сделаем, то каждая нить запустит свою сетку нитей, т. е. гораздо больше, чем нам нужно. Также обратите внимание, что мы можем запустить ядро при помощи стандартного использования тройных угловых скобок из CUDA C – нам не нужны какие-то сложные или низкоуровневые команды для использования динамического параллелизма.
220
Оптимизация быстродействия в CUDA
При использовании динамического параллелизма в CUDA будьте аккуратны, чтобы не запускать ядро без лишней необходимости. Это обычно делается потому, что только определенная нить запускает следующую итерацию ядер.
Давайте завершим наш код: dp_mod = DynamicSourceModule(DynamicParallelismCode) hello_ker = dp_mod.get_function('dynamic_hello_ker') hello_ker(np.int32(0), grid=(1,1,1), block=(4,1,1))
Теперь мы запустим написанный код и в итоге получим такой результат:
Этот пример можно найти в файле dynamic_hello.py в соответствующей папке в репозитории.
Быстрая сортировка при помощи динамического параллелизма Давайте обратимся к более интересному и практическому применению динамического параллелизма – алгоритму быстрой сортировки. Как мы увидим, этот алгоритм хорошо подходит для распараллеливания. Начнем с краткого обзора. Быстрая сортировка – это рекурсивный алгоритм сортировки без привлечения дополнительной памяти (на месте), который имеет порядок, в среднем и лучшем случаях равный O(N logN), и худший порядок O(N2). Быстрая сортировка выполняется путем выбора произвольной точки в неотсортированном массиве, называемой опорной, и разделения всего массива на массив слева (содержащий все значения меньше, чем опорная точка) и массив справа (содержащий все значения, бóльшие или равные опорной точке) с опорной точкой между ними. Если один или оба из этих массивов имеют длину, бóльшую 1, то мы рекурсивно вызываем быструю сортировку для одного или обоих подмассивов. Быстрая сортировка может быть реализована в одну строку на Python при помощи функционального программирования: qsort = lambda xs : [] if xs == [] else qsort(filter(lambda x: x < xs[‑1] , xs[0:‑1])) + [xs[‑1]] + qsort(filter(lambda x: x >= xs[‑1] , xs[0:‑1]))
Динамический параллелизм 221 Теперь мы видим, где в игру вступает параллелизм, за счет того, что быстрая сортировка рекурсивно вызывается для левого и правого массивов. Мы можем видеть, как сортировка начинается с одной нити, работающей над начальным массивом, но к тому моменту, когда массивы станут маленькими, будет уже много нитей, взаимодействующих с ними. Пока реализуем это за счет запуска ядер из одной нити. Применим операторы импорта. (Нам нужно импортировать функцию shuffle из стандартного модуля работы со случайными числами для примера, который будет приведен далее.) from __future__ import division import numpy as np from pycuda.compiler import DynamicSourceModule import pycuda.autoinit from pycuda import gpuarray from random import shuffle
Напишем наше ядро для быстрой сортировки. Мы создадим функцию устройства для разбиения массива, которая возьмет на вход указатель на целое число, начало и конец части массива, который мы будем делить на части. Эта функция в качестве опорной точки будет использовать последнюю точку из подмассива. После своего завершения она вернет окончательное положение опорной точки: DynamicQuicksortCode=''' __device__ int partition(int * a, int lo, int hi) { int i = lo; int pivot = a[hi]; int temp; for (int k=lo; { if (a[k] < { temp = a[k] = a[i] = i++; } }
k 0) quicksort_ker>(a, lo, mid ‑ 1); if(hi ‑ (mid + 1) > 0) quicksort_ker>(a, mid + 1, hi); cudaStreamDestroy(s_left); cudaStreamDestroy(s_right); } '''
Давайте случайным образом перетасуем список из 100 целых чисел и используем наше ядро для сортировки получившегося массива. Обратите внимание, что мы запускаем ядро всего на одной нити: qsort_mod = DynamicSourceModule(DynamicQuicksortCode) qsort_ker = qsort_mod.get_function('quicksort_ker') if __name__ == '__main__': a = range(100) shuffle(a) a = np.int32(a) d_a = gpuarray.to_gpu(a) print 'Несортированный массив: %s' % a qsort_ker(d_a, np.int32(0), np.int32(a.size ‑ 1), grid=(1,1,1), block=(1,1,1)) a_sorted = list(d_a.get()) print 'Сортированный массив: %s' % a_sorted Эта программа доступна на GitHub в файле dynamic_quicksort.py в репозитории.
векторные типы Данных и Доступ к памяти Теперь мы рассмотрим векторные типы данных в CUDA. Это векторизованные версии стандартных типов данных, таких как int или double, но которые могут содержать несколько значений. Есть векторизованные версии 32-битовых ти-
Векторные типы данных и доступ к памяти 223 пов данных вплоть до размера 4 (например, int2, int3, int4 и float4), в то время как 64-битовые типы могут быть векторизованы только до своего двойного размера (например, double2 и long2). Для векторизованной переменной с 4 компонентами мы можем обращаться к отдельным элементам как к полям структуры С с именами x, y, z и w, в то время как для переменной с тремя компонентами можно использовать только x, y, z и только x и y для двукомпонентной переменной. Это может показаться бессмысленным, но использование данных типов может улучшить быстродействие при загрузке массивов из глобальной памяти. Давайте выполним небольшой тест, для того чтобы увидеть, как можно загружать переменные типа int4 из массива целых чисел и double2 из массива double. Для этого используем оператор reinterpret_cast: from __future__ import division import numpy as np from pycuda.compiler import SourceModule import pycuda.autoinit from pycuda import gpuarray VecCode=''' __global__ void vec_ker(int *ints, double *doubles) { int4 f1, f2; f1 = *reinterpret_cast(ints); f2 = *reinterpret_cast(&ints[4]); printf("Первый int4: %d, %d, %d, %d\\n", f1.x, f1.y, f1.z, f1.w); printf("Второй int4: %d, %d, %d, %d\\n", f2.x, f2.y, f2.z, f2.w); double2 d1, d2; d1 = *reinterpret_cast(doubles); d2 = *reinterpret_cast(&doubles[2]); printf("Первый double2: %f, %f\\n", d1.x, d1.y); printf("Второй double2: %f, %f\\n", d2.x, d2.y); }'''
Обратите внимание, как мы применяем оператор * для инициализации векторизованных переменных и как нам нужно перейти к следующему адресу для загрузки значений int4 и double2 при помощи оператора &:
Этот пример доступен на GitHub в файле vectorized_memory.py в репозитории.
224
Оптимизация быстродействия в CUDA
потокобезопасные атомарные операции Теперь мы познакомимся с атомарными операциями в CUDA. Атомарные операции являются очень простыми потокобезопасными операциями, которые изменяют один элемент или переменную в массиве в глобальной или разделяемой памяти, что обычно могло привести к состоянию гонки (race condi tion). Рассмотрим один пример. Пусть у нас есть некоторое ядро, и для каждой нити в какой-то момент задана локальная переменная х. Мы хотим найти максимальное значение по всем х и записать это значение в переменную, объявленную как __shared__ int x_largest. Это можно сделать, просто вызвав atomicMax(&x_largest, x)в каждой нити. Давайте разберем некий пример использования атомарных операций. Мы напишем небольшую программку для двух экспериментов: установим значение переменной равным 0 и затем в каждой нити будем добавлять 1; найдем максимальный номер среди всех нитей. Начнем, как и ранее, с установки tid, равного глобальному номеру нити, и затем введем глобальную переменную add_out, равную 0. Ранее мы для этого использовали одну нить, которая задавала значение переменной, но сейчас можем использовать atomicExch(add_out,0) сразу для всех нитей. Давайте выполним все команды импорта и напишем ядро вплоть до этого места: from __future__ import division import numpy as np from pycuda.compiler import SourceModule import pycuda.autoinit from pycuda import gpuarray import pycuda.driver as drv AtomicCode=''' __global__ void atomic_ker(int *add_out, int *max_out) { int tid = blockIdx.x*blockDim.x + threadIdx.x; atomicExch(add_out, 0);
Следует отметить, что хотя атомарные операции потокобезопасны, они не гарантируют, что все нити обратятся к ним в одно и то же время. Они могут быть выполнены разными нитями в разные моменты времени. Здесь это может быть проблемой, поскольку следующим шагом мы будем изменять add_out. Это способно привести к тому, что add_out будет сбрасываться в 0, после того как она уже была частично изменена некоторыми нитями. Попробуем во избежание подобной ошибки выполнить синхронизацию на уровне блока: __syncthreads();
Перестановки в пределах варпа 225 Теперь мы можем использовать atomicAdd, для того чтобы добавить к add_out единицу для каждой нити, что даст нам общее число нитей: atomicAdd(add_out, 1);
Найдем максимальное значение tid для всех нитей при помощи atomicMax. После этого мы можем завершить наше ядро: atomicMax(max_out, tid); } '''
Добавим к полученному код для тестирования. Попробуем запустить это ядро для одного блока из 100 нитей. Нам понадобятся всего лишь две переменные, поэтому выделим два объекта gpuarray размером в 1. Затем напишем результат: atomic_mod = SourceModule(AtomicCode) atomic_ker = atomic_mod.get_function('atomic_ker') add_out = gpuarray.empty((1,), dtype=np.int32) max_out = gpuarray.empty((1,), dtype=np.int32) atomic_ker(add_out, max_out, grid=(1,1,1), block=(100,1,1)) print 'Atomic operations test:' print 'add_out: %s' % add_out.get()[0] print 'max_out: %s' % max_out.get()[0]
Мы готовы запустить данный код:
Этот пример доступен на GitHub в файле atomic.py. в репозитории.
перестановки в преДелах варпа Сейчас мы обратимся к тому, что известно как перестановки в пределах варпа (warp shuffling). Это возможность CUDA, которая позволяет нитям, существующим в пределах одного варпа CUDA, взаимодействовать между собой путем непосредственного чтения и записи регистров друг друга (т. е. их локальных переменных), без использования переменных в разделяемой или глобальной памяти. Перестановки в пределах варпа гораздо быстрее, и их легче использовать, чем предыдущие два варианта. Это звучит слишком прекрасно, чтобы быть правдой, поэтому есть один нюанс – он заключается в том, что такое возможно только между нитями одного варпа CUDA, что ограничивает операции группами из 32 или меньшего числа нитей. Другим моментом является то, что
226
Оптимизация быстродействия в CUDA
можно использовать только те типы данных, которые занимают 32 или меньшее число бит. Это значит, что мы не можем переставлять 64-битовые значения типа long long или double в пределах варпа. Только 32-битовые (или меньшие по размеру) типы данных могут быть использованы с перестановкой в пределах варпа в CUDA! Это значит, что хотя мы можем применять int, float или char, мы не можем прибегать к типам double или long long.
Давайте вкратце рассмотрим варпы в CUDA, прежде чем продолжим начатое. (Вы также можете обратиться к разделу «Использование NSight для пони мания варпа в CUDA» главы 6.) Варп в CUDA – это минимальный блок, который состоит из 32 или меньшего числа нитей и выполняется на 32 ядрах CUDA1. Так же как сетка состоит из блоков, так и блоки состоят из одного или бóльшего числа варпов, в зависимости от числа нитей: если блок состоит из 32 нитей, то он будет использовать всего один варп; если же нитей 96, то он будет состоять из трех варпов. Даже если варп включает не менее 32 нитей, то он всего равно считается полноценным. Это значит, что блок, состоящий всего из одной нити, будет использовать 32 ядра. Еще это говорит о том, что блок из 33 нитей будет состоять из двух варпов. Давайте вспомним свойство варпа, с которым мы столкнулись в главе 6. Каждая нить варпа пройдет через все команды параллельно со всеми остальными нитями. То есть все нити варпа будут одновременно проходить через одни и те же команды, игнорируя те, которые не относятся к конкретной нити. Можно сделать вывод, что следует избегать любого ветвления в пределах варпа, как только можно. NVIDIA называет такую модель выполнения Simple Instruction Multiple Thread, или SIMT. Теперь вы должны понять, почему везде, где это было возможным, мы в данной книге использовали блоки из 32 нитей. Прежде чем двинуться дальше, нам следует познакомиться еще с одним понятием – дорожка (lane). В варпе это уникальный идентификатор для нити в пределах варпа, т. е. число от 0 до 31. Иногда его называют Lane ID. Начнем с простого примера: мы будем использовать команду __shfl_xor, для того чтобы поменять местами значения переменной между четными и нечетными дорожками (нитями) в пределах нашего варпа. Это можно сделать быстро и просто. Поэтому напишем наше ядро и посмотрим на его код, приводимый ниже: from __future__ import division import numpy as np from pycuda.compiler import SourceModule import pycuda.autoinit from pycuda import gpuarray ShflCode=''' 1
Это не совсем точно, так как обычно он выполняется на 16 ядрах, просто каждое ядро реализует две нити.
Перестановки в пределах варпа 227 __global__ void shfl_xor_ker(int *input, int * output) { int temp = input[threadIdx.x]; temp = __shfl_xor (temp, 1, blockDim.x); output[threadIdx.x] = temp; }'''
Здесь все нам знакомо, кроме __shfl_xor. А именно то, как отдельная нить видит данный процесс: это функция, которая на вход берет значение переменной temp из текущей нити. Она выполняет операцию xor между значением laneID текущей нити и 1, что даст либо соседа слева (если наименее значимый бит равен 1), либо соседа справа (если наименее значимый бит равен 0). Далее она посылает значение temp текущей нити соседу и извлекает его значение temp. Оно и будет возвращено как значение функции и записано в temp. Запишем это значение в выходной массив, что соответствует перестановке входных значений. Теперь напишем оставшуюся часть кода и проверим выходные значения: shfl_mod = SourceModule(ShflCode) shfl_ker = shfl_mod.get_function('shfl_xor_ker') dinput = gpuarray.to_gpu(np.int32(range(32))) doutout = gpuarray.empty_like(dinput) shfl_ker(dinput, doutout, grid=(1,1,1), block=(32,1,1)) print 'Входной массив: %s' % dinput.get() print 'массив после __shfl_xor: %s' % doutout.get()
Результатом работы этого кода будет:
Давайте выполним еще один пример, связанный с операцией перестановки. Прежде чем продолжить, реализуем операцию для сложения локальных переменных по всем нитям варпа. Давайте вспомним «наивный» алгоритм параллельного суммирования из главы 4, который довольно быстрый, но делает одно наивное допущение, якобы у нас столько процессоров, сколько элементов входного массива. В нашем случае это будет верно, так как мы будем работать с массивом из 32 или меньшего числа элементов. Мы используем функцию __shfl_down для реализации суммирования в пределах одного варпа. Функция __shfl_down берет переменную нити в качестве первого аргумента и работает путем сдвига переменной между нитями на заданное число шагов, обозначенное вторым аргументом, в то время как третий аргумент задает общий размер варпа. Реализуем это прямо сейчас. Если вы забыли, что такое «наивный» алгоритм параллельного суммирования, или не помните, как он должен работать, то обратитесь к главе 4. Мы запустим этот алгоритм при помощи функции __shfl_
228
Оптимизация быстродействия в CUDA
down и выполним его для массива чисел от 0 до 31. Затем сравним полученный результат со значением функции sum из NumPy для проверки корректности: from __future__ import division import numpy as np from pycuda.compiler import SourceModule import pycuda.autoinit from pycuda import gpuarray ShflSumCode=''' __global__ void shfl_sum_ker(int *input, int *out) { int temp = input[threadIdx.x]; for (int i=1; i < 32; i *= 2) temp += __shfl_down (temp, i, 32); if (threadIdx.x == 0) *out = temp; }''' shfl_mod = SourceModule(ShflSumCode) shfl_sum_ker = shfl_mod.get_function('shfl_sum_ker') array_in = gpuarray.to_gpu(np.int32(range(32))) out = gpuarray.empty((1,), dtype=np.int32) shfl_sum_ker(array_in, out, grid=(1,1,1), block=(32,1,1)) print 'Входной массив: %s' % array_in.get() print 'Просуммированное значение: %s' % out.get()[0] print 'Соответствует ли это Python''s sum? : %s' % (out.get()[0] == sum(array_in.get()) )
В результате мы получим:
Примеры из этого раздела содержатся файлах shfl_sum.py и shfl_xor.py в каталоге Chapter11 в репозитории.
вставка PtX-ассемблера прямо в коД Сейчас мы слегка затронем написание кода на PTX (Parallel Thread eXecution), который является псевдоассемблером, работающим на всех NVIDIA GPU, и компилируется при помощи JIT (JustInTime) к коду для конкретного GPU. Хотя это и не предназначено для повседневного применения, мы сможем при необходимости работать на более низком уровне, чем С. Одним из примеров использования является то, что появляется возможность легко дизассемблировать бинарный файл CUDA (выполнимый файл, библиотеку на стороне хоста или бинарный файл .cubin) и изучить его PTX-код, если код на С недоступен.
Вставка PTX-ассемблера прямо в код 229 Это можно сделать при помощи команды cuobjdump.exe -ptx cuda_binary под Windows и Linux1. Как уже отмечалось, мы затронем лишь основы использования PTX из CUDA C, что очень похоже на использование ассемблера в GCC. Давайте приступим к написанию кода. Как и ранее, мы начнем с импорта и уже после непосредственно перейдем к коду для GPU: from __future__ import division import numpy as np from pycuda.compiler import SourceModule import pycuda.autoinit from pycuda import gpuarray PtxCode='''
Мы сделаем несколько мини-экспериментов по вставке кода в отдельные функции для устройства. Давайте начнем с простой функции, которая устанавливает значение входной переменной, равной 0. (Мы можем использовать ссылки языка С++ в функции устройства.) __device__ void set_to_zero(int &x) { asm("mov.s32 %0, 0;" : "=r"(x)); }
Немного остановимся, прежде чем перейти к дальнейшему обсуждению. Здесь asm, конечно, сообщает компилятору nvcc, что мы будем использовать ассемблер. Поэтому необходимо поставить соответствующий код в кавычки, чтобы он был надлежащим образом обработан. Команда mov просто копирует константу или другое значение и помещает его в регистр. (Регистр – это наиболее базовый тип памяти, используемый GPU или CPU для хранения или обработки значений. Это то, как в CUDA представлено большинство локальных переменных.) Часть .s32 команды mov.s32 обозначает, что мы работаем со знаковой 32-битовой целочисленной переменной – в РТХ-ассемблере нет типов данных в смысле языка С, поэтому нам нужно быть очень аккуратными в использовании операций. %0 сообщает nvcc, что нужно использовать регистр, соответствующий 0-му аргументу строки, и мы отделяем его от следующего входного значения для mov при помощи запятой. Данное значение является константой 0. Далее мы завершаем строку ассемблера при помощи точки с запятой и закрываем ее в двойные кавычки. Нам необходимо использовать двоеточие (не запятую), чтобы обозначить переменные, которые хотим применить в коде. Часть "=r" обозначает сразу две вещи: знак равенства (=), который сообщает nvcc, что регистр будет использоваться в качестве выходного значения, а r говорит о том, что для значения следует использовать 32-битовое целое число 1
.exe под Linux будет лишним. – Прим. перев.
230
Оптимизация быстродействия в CUDA
как тип данных. Поместим в круглые скобки переменную, которая должна обрабатываться ассемблером, и закроем директиву asm. Все это было нужно, чтобы задать значение переменной равным 0! Теперь давайте напишем небольшую функцию устройства, которая сложит для нас два числа с плавающей точкой: __device__ void add_floats(float &out, float in1, float in2) { asm("add.f32 %0, %1, %2 ;" : "=f"(out) : "f"(in1) , "f"(in2)); }
Обратите внимание на несколько моментов. Во-первых, мы используем add. f32 для обозначения того, что складываем два 32-битовых значения с плавающей точкой. Также мы используем "=f" для обозначения того, что будем записывать возвращаемое значение в регистр, и просто f для указания того, что мы предполагаем только читать из регистра. Также посмотрите, как мы используем двоеточие для отделения регистра, в который производится запись, от регистров, из которых мы будем только читать. Перед тем как продолжить, обратимся к еще одному примеру, т. е. функции, аналогичной оператору ++ в С, а именно увеличивающей значение целочисленной переменной на 1: __device__ void plusplus(int &x) { asm("add.s32 %0, %0, 1;" : "+r"(x)); }
Для начала обратите внимание, что мы используем 0-й параметр и как входной, и как выходной. Затем запомните, что мы применяем +r вместо =r. nvcc воспримет это как сообщение для себя, что данный регистр будет использоваться и для чтения, и для записи. Мы не станем рассматривать более сложные вещи, так как даже запись простого оператора if в ассемблере довольно трудна. Однако мы разберем еще несколько примеров, которые могут оказаться полезными при использовании варпов в CUDA. Давайте начнем с небольшой функции, которая даст нам номер «дорожки» для текущей нити. Это гораздо проще, чем в CUDA C, так как данный номер хранится в специальном регистре %laneid, который недоступен из С. (Обратите внимание, что здесь мы используем два символа %, для того чтобы сообщить nvcc, что в коде необходимо применять символ % для обращения к регистру, а не интерпретировать это как аргумент к команде asm.) __device__ int laneid() { int id; asm("mov.u32 %0, %%laneid; " : "=r"(id)); return id; }
Вставка PTX-ассемблера прямо в код 231 Напишем еще две функции, которые понадобятся нам при работе с варпами в CUDA. Как вы помните, мы можем передавать только 32-битовые переменные в пределах варпа при помощи команды перестановки (shuffle). Это значит, что для того чтобы передать 64-битовую переменную, мы должны разбить ее на две 32-битовые, выполнить перестановку отдельно для каждой из них и затем объединить их для получения 64-битового значения. Мы можем использовать команду mov.b64 для случая разбиения 64-битового double на 32-битовые целые числа. Заметьте, что мы должны использовать d для обозначения в качестве типа 64-битового double: __device__ void split64(double val, int & lo, int & hi) { asm volatile("mov.b64 {%0, %1}, %2; ":"=r"(lo),"=r"(hi):"d"(val)); } __device__ void combine64(double &val, int lo, int hi) { asm volatile("mov.b64 %0, {%1, %2}; ":"=d"(val):"r"(lo),"r"(hi)); } Если мы хотим понять, почему в следующем коде используется volatile, то это делается для того, чтобы гарантировать, что после компиляции данные команды будут выполнены в точности так, как они записаны. Мы вынуждены так поступать, потому что иногда компилятор может прибегнуть к своим собственным оптимизациям в ассемблерном коде. Однако для особенно чувствительных к этому операций, таких как приведенная, мы хотим, чтобы код был выполнен именно так, как он указан.
Напишем простое ядро, позволяющее нам проверить все функции на PTX, которые мы только что записали. Запустим его на одной нити, чтобы можно было все проверить: __global__ void ptx_test_ker() { int x=123; printf("x равно %d \\n", x); set_to_zero(x); printf("x сейчас равно %d \\n", x); plusplus(x); printf("x сейчас равно %d \\n", x); float f; add_floats(f, 1.11, 2.22 ); printf("f сейчас равно %f \\n", f); printf("lane ID: %d \\n", laneid() ); double orig = 3.1415; int t1, t2; split64(orig, t1, t2); double recon; combine64(recon, t1, t2); printf("split64 / combine64 работает? : %s \\n", (orig == recon) ? "да" : "нет"); }'''
232
Оптимизация быстродействия в CUDA
ptx_mod = SourceModule(PtxCode) ptx_test_ker = ptx_mod.get_function('ptx_test_ker') ptx_test_ker(grid=(1,1,1), block=(1,1,1))
Выполним этот код и получим следующий вывод:
Этот пример также доступен как файл ptx_assembly.py в каталоге Chapter11 в репозитории.
оптимизированная по быстроДействию версия суммирования элементов массива В качестве заключительного примера для этой книги мы рассмотрим стандартное ядро для суммирования массива из double, только на этот раз будем использовать каждый прием, который узнали в данной главе, для того чтобы сделать код как можно более быстрым. Мы сравним результат ядра с функцией sum из NumPy и затем выполним несколько тестов, используя стандартную функцию timeit из Python, для сравнения того, насколько наше ядро быстрее функции sum из PyCUDA для суммирования элементов gpuarray. Начнем с импортирования всех необходимых библиотек, например с функции laneid, аналогичной той, которую мы рассматривали в предыдущем разделе: from __future__ import division import numpy as np from pycuda.compiler import SourceModule import pycuda.autoinit from pycuda import gpuarray import pycuda.driver as drv from timeit import timeit SumCode=''' __device__ void __inline__ laneid(int & id) { asm("mov.u32 %0, %%laneid; " : "=r"(id)); }
Обратите внимание на несколько моментов. Мы использовали специальную декларацию __inline__ в описании функции. Это фактически превратит нашу функцию в макрос, что сэкономит немного времени на вызов ее из ядра. Также заметьте, что мы возвращаем переменную id по ссылке вместо оператора re-
Оптимизированная по быстродействию версия суммирования элементов массива 233 turn. В противном случае могли бы быть использованы два регистра и команда копирования. А такой подход гарантирует нам, что этого не произойдет. Теперь давайте аналогичным образом напишем остальные функции устройства. Нам понадобятся две дополнительные функции для разбиения и слияния 64-битового double на два 32-битовых целых числа: __device__ void __inline__ split64(double val, int & lo, int & hi) { asm volatile("mov.b64 {%0, %1}, %2; ":"=r"(lo),"=r"(hi):"d"(val)); } __device__ void __inline__ combine64(double &val, int lo, int hi) { asm volatile("mov.b64 %0, {%1, %2}; ":"=d"(val):"r"(lo),"r"(hi)); }
Приступим к написанию самого ядра. Примем на вход массив из double под именем input и вернем сумму через переменную out, которая должна быть проинициализирована 0. Начнем с получения номера дорожки для текущей нити и загрузки двух значений из глобальной памяти при помощи векторизованных типов данных: __global__ void sum_ker(double *input, double *out) { int id; laneid(id); double2 vals = *reinterpret_cast ( &input[(blockDim.x*blockIdx.x + threadIdx.x) * 2] );
Далее сложим их и поместим результат в новую double переменную под именем sum_val, которая будет отвечать за все сложения в данной нити. Мы создадим два 32-битовых целых числа, s1 и s2, которые мы будем использовать для разбиения sum_val и перестановки в пределах варпа. После чего введем новую переменную temp, собранную из полученных из других нитей варпа значений: double sum_val = vals.x + vals.y; double temp; int s1, s2;
Используем наивное параллельное сложение в пределах варпа, которое не будет отличаться от сложения 32-битовых целых чисел в пределах варпа, за исключением того, что мы собираемся использовать PTX-функции split64 и combine64 для sum_val и temp: for (int i=1; i < 32; i *= 2) { // используем а PTX assembly для разбиения числа split64(sum_val, s1, s2); // используем shuffle для перестановки данных
234
Оптимизация быстродействия в CUDA
s1 = __shfl_down (s1, i, 32); s2 = __shfl_down (s2, i, 32); // используем PTX assembly для сборки чиса combine64(temp, s1, s2); sum_val += temp; }
Сейчас нам нужно, чтобы 0-я нить каждого варпа добавила свое значение к out при помощи потокобезопасного atomicAdd: if (id == 0) atomicAdd(out, sum_val); }'''
Напишем тестовый код, использующий функцию timeit для измерения среднего времени работы этого ядра и функции sum из PyCUDA на 20 итерациях с массивом из 1000×2×32 значений double: sum_mod = SourceModule(SumCode) sum_ker = sum_mod.get_function('sum_ker') a = np.float64(np.random.randn(10000*2*32)) a_gpu = gpuarray.to_gpu(a) out = gpuarray.zeros((1,), dtype=np.float64) sum_ker(a_gpu, out, grid=(int(np.ceil(a.size/64)),1,1), block=(32,1,1)) drv.Context.synchronize() print sum_ker дает то же значение, что и NumPy\'s sum (через allclose)? : %s' % np.allclose(np.sum(a) , out.get()[0]) print 'Выполняем оценку времени sum_ker / PyCUDA sum (по 20 каждое)...' sum_ker_time = timeit('''from __main__ import sum_ker, a_gpu, out, np, drv \nsum_ker(a_gpu, out, grid=(int(np.ceil(a_gpu.size/64)),1,1), block=(32,1,1)) \ndrv.Context.synchronize()''', number=20) pycuda_sum_time = timeit('''from __main__ import gpuarray, a_gpu, drv \ngpuarray.sum(a_gpu) \ndrv.Context.synchronize()''', number=20) print 'sum_ker среднее время : %s, PyCUDA\'s gpuarray.sum среднее время: %s' % (sum_ker_time, pycuda_sum_time) print '(Улучшение быстродействия sum_ker по сравнению с gpuarray.sum: %s )' % (pycuda_sum_time / sum_ker_time)
Запустим этот код из IPython. Убедитесь в том, что вы уже запускали и gpuarray.sum, и sum_ker, для того чтобы убедиться, что мы не тратим время на компиляцию nvcc:
Вопросы 235 Хотя само суммирование довольно просто и скучно, но за счет правильного использования различных приемов мы смогли ощутимо ускорить этот тривиальный алгоритм. Этот пример доступен как файл performance_sum_ker.py в каталоге Chapter11 в репозитории.
резюме Мы начали главу с изучения динамического параллелизма, который является парадигмой, позволяющей запускать ядра и управлять ими прямо из GPU из других ядер. Далее увидели, как это можно использовать для реализации быстрой сортировки, прямо на GPU. Мы познакомились с векторизованными типами данных в CUDA и узнали, каким образом можно их использовать для ускорения чтения из глобальной памяти. Следующим шагом было упоминание о варпах в CUDA, которые являются блоками из 32 или меньшего числа нитей на GPU. Мы увидели, как нити в пределах одного варпа могут читать и писать в регистры друг друга при помощи перестановок в пределах варпа. Далее мы рассмотрели, как можно реализовывать некоторые базовые операции при помощи РТХ-ассемблера, включая такие операции, как определение номера дорожки, разбиение 64-битовой переменной на две 32-битовые. И наконец, мы завершили главу написанием нового оптимизированного ядра для сложения массивов из double, применяя для этого практически все приемы, которые узнали. Мы увидели, что это действительно быстрее, чем выполнение стандартной функции sum из PyCUDA для массивов с числом элементов порядка 500 000. В итоге мы прошли все главы данной книги, касающиеся технических процессов и их описаний! Вы должны гордиться собой, поскольку теперь являетесь опытным программистом GPU, знакомым со многими приемами. Теперь мы перейдем к заключительной главе, где вкратце рассмотрим несколько направлений, которые помогут вам применить и расширить полученные знания по программированию GPU.
вопросы 1. В примере с атомарными операциями попробуйте изменить размер сетки с 1 до 2 перед запуском ядра, оставив при этом общий размер блока, равный 100. Если это даст вам неверный ответ для add_out (что-то отличающееся от 200), то почему такое происходит, учитывая, что функция atomicExch является потокобезопасной? 2. В примере с атомарными операциями попробуйте убрать __syncthreads и затем запустите ядро с исходными параметрами – размером сетки 1 и размером блока 100. Если это даст вам неверный ответ (что-либо отличающееся
236
Оптимизация быстродействия в CUDA
от 100), то почему такое происходит, учитывая, что функция atomicExch является потокобезопасной? 3. Почему нам не нужно использовать __syncthreads для синхронизации блока размером 32 или меньше? 4. Мы увидели, что sum_ker примерно в 5 раз быстрее операции sum из PyCUDA для массивов из случайных чисел длиной 640 000 (10000×2×32). Если вы попытаетесь добавить 0 к концу этого числа (т. е. умножив его на 10), то заметите, что быстродействие sum_ker всего в 1,5 раза выше, чем быстродействие функции sum из PyCUDA. Как вы думаете, почему это происходит? Как мы можем улучшить sum_ker для больших массивов? 5. Какой алгоритм выполняет больше операций сложения (считая как вызовы оператора + из С и вызовы atomicSum): sum_ker или функция sum из PyCUDA?
Глава
12 Куда идти далее?
Эта книга была своеобразным путешествием, почти как вершина горы… но сейчас, наконец, мы дошли до ее завершения. Мы стоим на вершине горы под названием «Введение в программирование GPU» и с гордостью смотрим вниз, на деревню последовательного программирования, и улыбаемся, думая о наивности наших старых одномерных традиций, где операция fork фактически полностью покрывала наши представления о параллельном программировании. Мы преодолели многие проблемы и опасности на пути и смогли совершить даже такие ошибки, как установка неработающего драйвера для Linux или скачивание неподходящей версии Visual Studio, через медленное, 100 К, подключение к интернету во время посещения ваших родителей. Но все эти неприятности были временными, оставляя раны, которые превратились в мозоли, сделав нас еще сильнее по сравнению с силами (GPU) природы. Но краем глаза мы можем видеть два деревянных знака в нескольких метрах от нас. Мы отворачиваем глаза от деревни и смотрим на эти условные обозначения. Одно из них содержит стрелку, указывающую направление, куда мы только что смотрели, и состоит всего лишь из одного слова – ПРОШЛОЕ. Другое показывает противоположное направление и также содержит всего одно слово – БУДУЩЕЕ. И мы видим огромный блестящий город, раскинувшийся до горизонта, как бы зовущий нас. Теперь, когда мы, наконец, перевели дыхание, можно начать двигаться в сторону будущего… В этой главе мы рассмотрим несколько вариантов, позволяющих вам обучиться серьезным вещам и сделать карьеру в областях, связанных с программированием GPU. Решите ли вы сделаться грамотным специалистом после изучения GPU на занятиях, программистом или инженером, желающим развить свои познания, либо ученым, пытающимся применить GPU в исследовательском проекте, у вас есть огромное количество вариантов и возможностей. Также для вас это может остаться только в качестве хобби. В нашем воображаемом городе очень легко можно заблудиться, поэтому сложно определить, куда следует идти. Надеюсь в заключительной главе предоставить краткий обзор, показав вам некоторые вариации, куда вы можете двинуться дальше.
238
Куда идти далее?
В этой главе мы рассмотрим несколько возможных направлений: продвинутое программирование на CUDA; графику; машинное обучение и компьютерное зрение; технологию блокчейн.
расширение знаний о CUDA и программировании GPGPU Первым вариантом для вас, конечно, является узнать больше про CUDA и программирование общего назначения для GPU (GPGPU) в частности. Вы наверняка уже нашли этому неплохое применение и хотите писать более продвинутый или оптимизированный код на CUDA. По большому счету, вы уже должны были определиться, для чего вам нужны эти знания: просто ради интереса или же как подспорье для получения престижной работы в качестве программиста CUDA/GPU. После приобретенных навыков по программированию GPU (как те, которые были предоставлены в этой книге) обратимся к некоторым продвинутым темам в данной области, о которых вам необходимо знать.
Системы из нескольких GPU Первой серьезной темой, которая приходит в голову, будет изучение программирования систем, содержащих более одного GPU. Многие профессиональные рабочие станции и серверы содержат несколько GPU, которые обычно установлены с целью обработки гораздо бóльшего объема данных, требующего не одного, а сразу нескольких GPU. Для этого существует такое направление, как программирование нескольких GPU (Multi-GPU Programming). Значительная часть работы в этом направлении заключается в балансировке нагрузки, которая основывается на использовании каждого GPU на пределе его возможностей, гарантируя, что никакой GPU не будет перегружен, в то время как другие графические процессоры полностью не задействованы. Другим направлением является взаимодействие между GPU (Inter-GPU Communication), заключающееся в возможности процессоров копировать массивы данных в/из памяти других GPU при помощи CUDA GPUDirect (piertopier memory access). NVIDIA предоставляет краткое руководство по программированию нескольких GPU по адресу: https://www.nvidia.com/docs/IO/116711/sc11-multi-gpu.pdf.
Кластерные вычисления и MPI Другой областью являются вычисления на кластерах, т. е. написание программ, которые используют множество серверов, содержащих GPU. Это так называемые серверные фермы (server farm), которые в больших количествах используются для обработки данных в широко известных интернет-компаниях, таких как Facebook и Google, а также в научных суперкомпьютерных центрах, нахо-
Графика 239 дящихся в ведении государства и военных. Кластеры обычно программируются с использованием парадигмы передачи сообщений (Message Passing Interface, MPI), которая является интерфейсом, используемым такими языками, как С++ или Fortran, для программирования большого числа компьютеров, подключенных к одной и той же сети. Дополнительную информацию об использовании CUDA вместе с MPI можно найти по адресу: https://devblogs.nvidia.com/introduction-cuda-aware-mpi/.
OpenCL PyOpenCL CUDA не является единственным языком, который можно использовать для программирования GPU. Самым главным соперником CUDA выступает открытый язык для вычислений (Open Computing Language, OpenCL). В то время как CUDA – это закрытая система, которая работает только на оборудовании от NVIDIA, OpenCL – открытый стандарт, что был разработан и поддерживается некоммерческой организацией Khronos Group1. OpenCL может быть использован не только для программирования GPU от NVIDIA, но также и Radeon GPU от AMD и даже Intel HD GPU – большинство главных технологических компаний поддерживают OpenCL в своих продуктах. Кроме того, автор PyCUDA профессор Андреас Клекнер из UIUC также написал великолепную (и бесплатную!) библиотеку для Python – PyOpenCL, которая предоставляет удобный интерфейс к OpenCL и почти тот же синтаксис и понятия, что и PyCUDA. Информация об OpenCL предоставляется компанией NVIDIA по следующей ссылке: https://developer.nvidia.com/opencl. Информация о библиотеке PyOpenCL доступна на веб-сайте Андреаса Клекнера по адресу: https://mathema.tician.de/software/pyopencl/.
графика Очевидно, что буква G в слове GPU обозначает графику, и вы до сих пор ее практически не видели в этой книге. Даже притом, что сейчас приложения в области машинного обучения – это «масло и хлеб» для компании NVIDIA, все это начиналось с рендеринга красивой графики. Будут показаны некоторые ресурсы, для того чтобы вы могли начать освоение данной области, хотите ли вы разрабатывать движки для графических систем, заниматься рендерингом графики для фильмов или разрабатывать приложения в области CAD. На самом деле CUDA может быть успешно использована в графических приложениях и уже применяется в таких программах, как Photoshop и After Effects от Adobe, а также во многих недавних видеоиграх, таких как серии Mafia и Just Cause. Я вкратце расскажу об основных API, с которых вы можете начать. 1
Это неверно. На самом деле изначально OpenCL был разработан компанией Apple. – Прим. перев.
240
Куда идти далее?
OpenGL OpenGL является открытым стандартом, существующим с начала 90-х гг. Хотя в некоторых аспектах его возраст сказывается, тем не менее это стабильный API с широкой поддержкой. И если вы пишете программу с его использованием, то, скорее всего, она сможет работать почти на всех современных GPU. Папка с примерами использования CUDA содержит множество вариантов того, как OpenGL может быть применен вместе с CUDA (в частности, в каталоге 2_Graphics), поэтому заинтересованным читателям стоит просмотреть указанные примеры. (По умолчанию они расположены в каталоге C:\ProgramData\ NVIDIA Corporation\CUDA Samples под Windows и в каталоге /usr/local/cuda/samples под Linux.) Информация об OpenGL доступна на сайте NVIDIA по адресу: https://developer.nvidia. com/opengl1. PyCUDA также предоставляет интерфейс к драйверу OpenGL, информация о котором доступна по адресу: https://documen.tician.de/pycuda/gl.html.
DirectX12 DirectX12 – это последняя версия широко известного и поддерживаемого графического API от компании Microsoft2. Хотя это проприетарный API для Microsoft Windows и игровых консолей Microsoft Xbox, данные системы имеют огромную базу пользователей, насчитывающую сотни миллионов человек. Кроме того, персональные компьютеры под управлением Windows поддерживают, кроме MVDIA GPU, многие другие процессоры, и Visual Studio облегчает его использование. DirectX12 на самом деле поддерживает ряд понятий из низкоуровневого программирования и может работать с несколькими GPU. Руководство по программированию для DirectX12 от Microsoft можно найти по адресу: https://docs.microsoft.com/en-us/windows/desktop/direct3d12/directx-12-programming-guide.
Vulkan Vulkan можно рассматривать как открытый эквивалент DirectX12, который был разработан Khronos Group как API следующего поколения на смену OpenGL. Наряду с Windows Vulkan поддерживается на macOS и Linux, а также на Sony PlayStation 4, Nintendo Switch и консолях Xbox3. Vulkan является серьезным соперником DirectX12, и, например, на нем была написана Doom 4. 1 2
3
Лучше все-таки обратиться к официальному сайту http://www.opengl.org. – Прим. перев. На самом деле этот API поддерживает не только графику. За графику отвечает его часть Direct3D. – Прим. перев. Также он поддерживается на большом числе мобильных устройств под управлением Android. – Прим. перев.
Машинное обучение и компьютерное зрение 241 Руководство для начинающих по работе с Vulkan доступно на сайте Khronos Group по адресу: https://www.khronos.org/blog/beginners-guide-to-vulkan.
машинное обучение и компьютерное зрение Конечно, условно говоря, слоном в комнате для этой главы является машинное обучение и тесно связанное с ним компьютерное зрение. Можно даже не говорить о том, что именно машинное обучение (в частности, такие направления в нем, как глубокие нейронные сети и сверточные нейронные сети) – это то, что держит крышу над CEO NVIDIA Дженсена Хуанга в наши дни. (Хорошо, давайте признаем, что это на самом деле некоторое преуменьшение.) Если вам нужно напоминание, почему GPU так применимы и полезны в данной области, обратитесь к главе 9 «Реализация глубокой нейросети». Большой объем параллельных вычислений и математических операций, так же как и удобные для применения математические библиотеки, сделали GPU от NVIDIA основой современной индустрии машинного обучения.
Основы Хотя вы сейчас знаете многие тонкости низкоуровневого программирования GPU, не получится их тут же применить к машинному обучению. Если у вас нет начальных знаний в этой области, например как выполнить базовый статистический анализ данных, то вам нужно остановиться и сперва ознакомиться с ними. У профессора Стенфорда Эндрю Нг, основателя Google Brain, есть много материалов, к которым свободный доступ в интернете и на YouTube. Работы профессора Нг считаются «золотым стандартом» образовательных пособий по машинному обучению. Бесплатный вводный курс по машинному обучению от профессора Нг находится по адресу: http://www.ml-class.org.
cuDNN NVIDIA предоставляет оптимизированную для GPU библиотеку с примитивами по машинному обучению, называемую cuDNN. Эти примитивы включают в себя такие операции, как прямое распространение (forward propagation), свертки, обратное распространение (back propagation), функции активации (такие как сигмоидная функция, ReLU и tanh) и градиентный спуск. Большинство распространенных библиотек по глубоким нейросетям, такие как Tensorflow, используют cuDNN для работы с GPU. Она бесплатно предоставляется NVIDIA, но не входит с CUDA Toolkit, и ее нужно скачивать отдельно. Дополнительную информацию по cuDNN можно найти по адресу: https://developer. nvidia.com/cudnn.
242
Куда идти далее?
Tensorflow и Keras Tensorflow, конечно же, – это широко известная библиотека от компании Google по работе с нейросетями. Она бесплатная, имеет открытый исходный код, может быть использована из Python и из С++ и доступна с 2015 г. Уроки по Tensorflow от Google доступны по адресу: https://www.tensorflow.org/tutorials/.
Keras – это высокоуровневая библиотека, предоставляющая более дружелюбный к пользователю интерфейс к Tensorflow, которая была изначально написана разработчиком Google Brain Франсуа Коллетом. Читателям рекомендуется начать с Keras и только потом приступать к Tensorflow. Информация о Keras доступна по адресу: https://keras.io/.
Chainer Еще одной библиотекой по работе с нейросетями является Chainer, разработанная Сея Токуи, который на данный момент является аспирантом в университете Токио в Японии. Хотя она менее распространена, чем Tensorflow, к ней относятся с большим уважением из-за невероятной скорости и эффективности. Более того, читатели могут найти Chainer особенно интересной, поскольку изначально она была разработана с использованием PyCUDA. (Позже библиотека перешла на применение модуля CuPy, который является ответвлением PyCUDA, с целью предоставления интерфейса, более похожего на NumPy.) Информацию по Chainer можно найти по адресу: https://chainer.org/.
OpenCV Открытая библиотека для компьютерного зрения (OpenCV) существует с 2001 г. Она предоставляет многие инструменты из классического компьютерного зрения и обработки изображений, которые все еще очень полезны в наше время глубоких нейронных сетей. Большинство алгоритмов OpenCV были в последние годы портированы на CUDA, и эта библиотека довольно легко взаимодействует с PyCUDA. Информация по OpenCV доступна по адресу: https://opencv.org/.
технология блокчейн Последней в списке, но отнюдь не наименее важной, является технология блокчейн (blockchain). Это криптографическая технология, которая лежит в основе криптовалют, таких как Bitcoin и Ethereum. Это, конечно, новая область, которая была впервые описана таинственным создателем Bitcoin Сато-
Вопросы 243 ши Накамото в публикации от 2008 г. Почти сразу же после открытия GPU были применены в этой области, т. е. для получения денег путем прямого перебора при решении криптографической задачи, и GPU может параллельно проверять больше комбинаций, чем любой другой доступный широкой публике вычислитель. Этот процесс называется майнингом. Интересующимся технологией блокчейн рекомендуется ознакомиться с публикацией Сатоши Накамото по биткойну, которая доступна по адресу: https://bitcoin.org/bitcoin.pdf. Открытый майнер биткойна на CUDA, GUIMiner, доступен по адресу: https://guiminer.org/.
резюме В этой главе мы разобрали несколько вариантов и путей для тех, кто заинтересован в дальнейшем углублении своих знаний по программированию GPU, которые лежат за границами данной книги. Первый путь, который мы разобрали, заключается в расширении ваших познаний в CUDA и GPGPU – некоторые темы, которые не были раскрыты в книге, включая программирование систем с несколькими GPU и кластеров. Мы также посмотрели на другие языки и API для параллельного программирования, кроме CUDA, включая MPI и OpenCL. Далее мы познакомились с некоторыми широко известными API, доступными для тех, кто решил применять GPU для рендеринга графики, такими как Vulkan и DirectX 12. Затем обратились к машинному обучению и к некоторым базовым знаниям, которыми вы должны обладать, а также рассмотрели известные библиотеки для разработки глубоких нейросетей. В конце мы коснулись технологии блокчейн и майнинга при помощи GPU. Как автор я хотел бы сказать спасибо каждому, кто прочел книгу и дошел до этого места. Программирование GPU – одна из наиболее тонких областей программирования, с которыми мне довелось столкнуться, и я надеюсь, что эта книга помогла вам разобраться с ее основами. Как читатель вы сейчас должны взяться за самый калорийный кусок шоколадного кекса, который сможете найти, – зная, что вы его заслужили (но только один кусок!).
вопросы 1. Используйте Google или другую поисковую систему, для того чтобы найти как минимум одно приложение, использующее программирование GPU, которое не рассмотрено в этой главе. 2. Попробуйте найти как минимум один язык или API, которые могут быть использованы для программирования GPU, но не рассматривались в этой главе. 3. Посмотрите на тензорные процессоры от Google (Tensor Processing Unit, TPU). Насколько они отличаются от GPU? 4. Как вы думаете лучше присоединять компьютеры к кластерам: при помощи Wi-Fi или проводного Ethernet?
Ответы на вопросы глава 1 «почему программирование GPU?» 1. Первые два цикла for обходят все пикселы, и их результаты не зависят друг от друга, поэтому мы можем распараллелить эти два цикла. Третий цикл вычисляет окончательное значение конкретного пиксела и рекурсивен. 2. Закон Амдала не учитывает время, требуемое для копирования данных между CPU и GPU. 3. 512×512 соответствует 262 144 пикселам. Это значит, что первый GPU может сразу вычислить результаты для первой половины пикселов, в то время как второй GPU может сразу вычислить все пикселы. Отсюда вывод, что в данном случае второй GPU вдвое быстрее первого. У третьего GPU ядер больше, чем нужно, но, как мы видели в задаче 1, лишние ядра нам никак не помогут. Поэтому для этой задачи второй и третий GPU будут одинаково быстрыми. 4. Для того чтобы применять закон Амдала для оценки распараллеливания определенного фрагмента кода, необходимо, чтобы время выполнения этого фрагмента было близко к нулю при большом числе процессоров N. Как видно из постановки задачи, это далеко не так. 5. Во-первых, аккуратно использовать time может быть довольно неудобным, и оно не всегда окажется равным нулю в критических точках вашей программы. Во-вторых, профайлер может сообщить вам точное время выполнения кода с точки зрения Python, так что вы можете понять, что проблема заключается в какой-то библиотечной функции или фоновой деятельности вашей операционной системы, а не в вашем коде.
глава 2 «настройка окружения Для программирования GPU» 1. Нет, CUDA поддерживается только на NVIDIA GPU, а не на Intel HD или Radeon. 2. В этой книге используются примеры только на Python 2.7. 3. Менеджер устройств (Device Manager). 4. lspci. 5. free. 6. .run.
Ответы на вопросы 245
глава 3 «начало работы с PyCUDA» 1. 2. 3. 4.
Да. Копирование памяти между хостом и устройством и время компиляции. Вы можете, но это будет зависеть от настройки ваших CPU и GPU. Сделайте это при помощи оператора ? из языка С как для поточечных операций, так и для операции reduce. 5. Если объект класса gpuarray выходит из области видимости, то вызывается его деструктор, который автоматически освободит выделенную на GPU память. 6. ReductionKernel может выполнять избыточные операции, которые требуются в зависимости от структуры кода на GPU. Нейтральный элемент гарантирует, что никакие значения не будут изменены в результате этих избыточных операций. 7. Нам нужно задать neutral равным наименьшему возможному значению для 32-битовых целых чисел.
глава 4 «яДра, нити, блоки и сетки» 1. Попробуйте. 2. Все нити не выполняются на GPU одновременно. Как CPU переключается при помощи операционной системы между задачами, так и отдельные ядра GPU переключаются между отдельными нитями ядра. 3. O(n/640 log n), т. е. O(n log n). 4. Попробуйте. 5. В CUDA на самом деле нет синхронизации на уровне сетки – только на уровне блока (при помощи __syncthreads). Все, что находится выше блока, должно синхронизироваться при помощи хоста. 6. Наивный: 129 операций сложения. Эффективный: 69 операций сложения. 7. Опять мы не можем использовать __syncthreads, если нам нужно синхронизировать сетку блоков. Мы можем запускать на меньшем числе нитей на каждую итерацию, если синхронизируемся на хосте, освобождая больше ресурсов для других операций. 8. В случае «наивного» параллельного суммирования мы, скорее всего, будем работать с небольших числом значений, которое будет меньше или равно числу ядер в GPU, что наверняка будет не больше максимального числа нитей в блоке (1024). Поэтому мы можем синхронизироваться внутри блока. Нам следует использовать эффективный алгоритм, только если число элементов больше, чем число доступных ядер GPU.
246
Ответы на вопросы
глава 5 «потоки, события, контексты и оДновременность» 1. Быстродействие улучшается в обоих случаях; по мере увеличения числа нитей GPU достигает максимальной утилизации в обоих случаях, уменьшая выигрыш от использования потоков. 2. Да, вы можете запустить произвольное число ядер асинхронно и синхронизировать их при помощи cudaDeviceSynchronize. 3. Откройте ваш текстовый редактор и попробуйте! 4. Высокое отклонение означает, что ваш GPU использовался неравномерно: в каких-то местах был перегружен, в каких-то недогружен. Низкое отклонение означает, что все запущенные операции выполнялись равномерно. 5. i. Хост обычно может поддерживать гораздо меньше параллельно выполняющихся нитей, чем GPU. ii. Каждая нить требует своего контекста CUDA. GPU может быть перегружен излишними контекстами, поскольку у каждого есть свое пространство в памяти и каждый должен обрабатывать свой загруженный код.
глава 6 «отлаДка и профилирование вашего коДа на CUDA» 1. Выделение памяти автоматически синхронизируется в CUDA. 2. Данное свойство выполняется только для блоков размером не более 32 нитей. Здесь два блока могут спокойно идти по разным веткам без ветвления. 3. Здесь происходит то же самое. Этот блок из 64 нитей на самом деле разбивается на два варпа. 4. Nvprof может замерять запуски отдельных ядер, утилизацию GPU и использование потоков; любой профайлер на стороне хоста может видеть только вызовы функций CUDA на стороне хоста. 5. Printf обычно гораздо легче использовать для небольших проектов с относительно небольшими ядрами. Если вы напишете очень сложное ядро CUDA, состоящее из тысяч строк, то, скорее всего, вам лучше использовать IDE для отладки ядра строка за строкой. 6. Это указывает CUDA, какой именно GPU надо использовать. 7. cudaDeviceSynchronize гарантирует, что взаимозависимые запуски ядер и копирование памяти на самом деле синхронизированы и не начнут выполняться, прежде чем не завершатся все необходимые операции.
Ответы на вопросы 247
глава 7 «использование библиотек CUDA вместе со sCiKit-CUDA» 1. SBLAH начинается с S, поэтому данная функция использует 32-битовые вещественные значения с плавающей точкой. ZBLEH начинается с Z, поэтому она работает с 128-битовыми комплексными числами. 2. Подсказка: задайте trans = cublas._CUBLAS_OP['T']. 3. Подсказка: используйте SciKit-CUDA для вычисления скалярного произведения skcuda.cublas.cublasSdot. 4. Подсказка: используйте ответ к предыдущему вопросу. 5. Вы можете поместить операции cuBLAS в поток CUDA и использовать объект-события в этом потоке для точного измерения времени вычисления на GPU. 6. Поскольку входные значения для cuFFT являются комплексными, то будут вычислены точно такие же значения, как и в NumPy. 7. Темное ребро получается из-за окружения изображения нулями. С этим можно бороться при помощи отзеркаливания изображения по краям, а не окружения нулями.
глава 8 «библиотеки функций Для CUDA GPU и thrUst» 1. Попробуйте (это гораздо точнее, чем вам может показаться на первый взгляд). 2. Одно применение: распределение Гаусса может быть использовано для добавления белого шума к входным данным для машинного обучения. 3. Нет, поскольку они получаются от разных стартовых значений. Эти списки могут обладать сильной корреляцией, если мы их соединим вместе. Мы должны использовать подпоследовательности с одним и тем же стартовым значением, если планируем их соединять вместе. 4. Попробуйте! 5. Подсказка: вспомните, что умножение матриц можно рассматривать как набор операций умножения матрицы на вектор, в то время как умножение матрицы на вектор – как набор операций вычисления скалярного произведения. 6. Для определения функции используется operator().
248
Ответы на вопросы
глава 9 «реализация глубокой нейросети» 1. Одна из проблем может заключаться в том, что вы не нормализовали входные данные. Другой может быть слишком высокая скорость обучения (rate). 2. С небольшой скоростью обучения набор весов может сходиться очень медленно или не сходиться вообще. 3. Большая скорость обучения может привести к тому, что вы получите подгонку именно под конкретные входные значения. Также это может вести к числовым переполнениям (overflow, underflow), как в первом вопросе. 4. Сигмоид. 5. Мягкий максимум. 6. Больше обновлений.
глава 10 «работа с компилированным коДом Для GPU» 1. Только .exe-файл может содержать функции хоста, но Ptx- и exe-файлы могут содержать код для GPU. 2. cuCtxDestroy. 3. printf с произвольными входными параметрами (попробуйте посмотреть на прототип printf). 4. При помощи объекта c_void_p из Ctypes. 5. Это позволит нам вызывать данную функцию по ее исходному имени при помощи Ctypes. 6. Выделение памяти устройства и копирование памяти между устройством и хостом в CUDA синхронизируются автоматически.
глава 11 «оптимизация быстроДействия в CUDA» 1. Тот факт, что atomicExch является потокобезопасной, не гарантирует, что все нити вызовут эту функцию в одно и то же время (поскольку разные блоки в сетке могут находиться в разных местах кода). 2. Блок размером в 100 нитей будет состоять из нескольких варпов, что не синхронизируется внутри блока, если мы не используем __syncthreads. Таким образом, atomicExch может быть вызвана много раз. 3. Поскольку все нити внутри варпа выполняются синхронно и блоки размером в 32 или менее нитей состоят из одного варпа, то вызов __syncthreads необязателен. 4. Мы использовали «наивное» параллельное суммирование внутри варпа, но в остальном, поскольку часто вызываем atomicAdd, получаем обычное последовательное сложение. Хотя CUDA автоматически распараллеливает многие из этих вызовов, мы можем уменьшить общее число вызовов atomicAdd, используя эффективный алгоритм параллельного суммирования.
Ответы на вопросы 249 5. Определенно sum_ker. Очевидно, что функция суммирования в PyCUDA не использует всех приемов, которые мы применяли, поскольку наше ядро лучше работает на небольших массивах. Но то, что мы наблюдаем при заметном увеличении размера массива, говорит о том, что PyCUDA выполняет меньше операций сложения.
глава 12 «куДа иДти Далее?» 1. Два примера: анализ ДНК и моделирование физических процессов. 2. Два примера: OpenACC и Numba. 3. TPU используются только для операций машинного обучения, и в них отсутствуют компоненты, необходимые для рендеринга графики. 4. Ethernet.
Предметный указатель Символы
__syncthreads(), использование, 77
A
Advanced Package Tool (APT), 39 Anaconda Python, 39
B
BLAS – Basic Linear Algebra Subprograms, 137
C
Chainer, cсылка 242 Ctypes, запуск компилированного кода, 202 cuBLAS контекст, 139 cuBLAS, 137, 139 cuBLAS функции уровня 1, 139 GEMV, 142 использование AXPY, 139 использование GEMM для измерения производительности GPU, 144 использование в линейной алгебре, 139 CUDA C программирование использование, 119 отладка при помощи NSight IDE, 124 CUDA Driver API, использование, 213 CUDA Math API, 159 вычисление определенных интегралов методом Монте-Карло, 166 написание тестов, 172 нахождение интегралов, 165, 166 CUDA Runtime API, 210 CUDA Thrust, 174 функторы, использование, 176 CUDA Toolkit установка, 38 установка на Linux, 38 установка под Windows, 39 CUDA ядра, 44 использование printf, 113, 115 CUDA варп, понимание при помощи NSight, 131 изучение, 238 c MPI, ссылка, 238, 239 CUDA-потоки, 81 использование в игре «Жизнь», 97 cuDNN, ссылка 241
cuFFT быстрое преобразование Фурье, 147 использование для двухмерной свертки, 150 простое 1D FFT, 148 cuRAND, библиотека оценка π при помощи метода Монте-Карло, 161 псевдослучайная последовательность, 160 стартовое значение (seed value), 160 cuSolver использование из Scikit-CUDA, 155 ссылка, 155
D
DirectX 12, ссылка 240
E
ElementWiseKernel из PyCUDA использование для осуществления поэлементных вычислений, 55 Мандельброт, 58 основа ядра редукции, 63 параллельное сканирование (parallel scan), 63 функциональное программирование, 61
F
FLOPS (операции с плавающей точкой в секунду), 144
G
GEMM – перемножение матрицы на матрицу общего вида, 144 GPGPU – программирование на GPU задач общего назначения, 18, 238 GPU запрос, 45 запрос через PyCUDA, 46 gpuarray класс, PyCUDA использование, 49 использование для выполнения поэлементных арифметических операций, 50 использование для копирования данных, 49
Предметный указатель GPU-драйверы установка, 33 установка под Linux, 33 установка под Windows, 35 GPU-программирование, настройка окружения Python, 39 GUIMiner, ссылка, 243
K
Keras, ссылка 242
M
MAGMA, ссылка, 138 Math Kernel Library (MKL), 28, 137 MPI (Message Passing Interface), 238
N
NSight IDE использование для отладки CUDA C, 124 использование для разработки на CUDA C, 124 NSight использование для понимания работы нитей внутри варпа в CUDA, 134 использование с Eclipse под Linux, 128 использование с Visual Studio под Windows, 125 Nvidia CUDA Driver API, документация, ссылка, 210 NVIDIA nvprof профайлер, использование, 134 NVIDIA Visual Profiler, использование, 125
O
OpenCL, ссылка 239 OpenCV, ссылка, 242 OpenGL, ссылка, 240
P
printf использование для отладки, 115 использование из ядер CUDA, 113 PTX (Parallel Thread eXecution), 208 PyCUDA, использование для опроса GPU, 46 PyOpenCL, 239 библиотека, ссылка 239 Python-окружение для программирования GPU PyCUDA, проверка, 42 PyCUDA, установка под Linux, 40 PyCUDA, установка под Windows, 41 настройка, 39 создание скрипта под Windows, 40
251
S
Scikit-CUDA cuSolver, использование, 155 ссылка, 139 установка, 139 Single Instruction Multiple Thread (SIMT), 226 SourceModule функция из PyCUDA, 67 Stream класс из PyCUDA, использование, 93 Streaming Multiprocessor, потоковый мультипроцессор, 47
T
Tensorflow, ссылка 242
V
Visual Studio (Windows), ссылка, 36 Vulkan, ссылка, 240
А
Активационная функция, 182 Алгоритм исключающей префиксной суммы, 85 Алгоритм параллельной префиксной суммы, 82 включающая, сравнение с исключающей, 85 наивный алгоритм параллельной префиксной суммы, 82 эффективный алгоритм параллельной префиксной суммы, 85 Алгоритм суммирования массива, оптимизированный по быстродействию, 87 Амдала закон, 19 использование, 21 Анализ главных компонент (Principal Component Analysis, PCA), 156 Аппаратное обеспечение проверка в Linux, 30 проверка под Windows, 31 требования, 29
Б
Барьер синхронизации на уровне блока, 77 Блоки, 67, 70 Блокчейн, технология, 242 Быстрая сортировка (QuickSort) с динамическим параллелизмом, 220 Быстрое преобразование Фурье (Fast Fourier Transform) использование в cuFFT, 147 использование для свертки, 150
252
Предметный указатель
В
Варп (warp), 225 Векторные типы данных, 222 Включающий алгоритм префиксной суммы (inclusive prefix algorithm), 85 Вычислительная способность, функция, 47
М
Гаусса фильтрация, 150 Глобальное устройство, память, 49 Глубокая нейросеть (DNN), 180 Градиент, 193 Градиентный спуск, 182, 193 Графика, 239 DirectX 12, 240 OpenGL, 240 Vulkan, 240 Графический процессор (GPU), 18
Майнинг, 243 Мандельброта множество, 22, 58, 202 взаимодействие с Ctypes, 206 код, компиляция, 202 Машинное обучение, 181 Chainer, 242 cuDNN, 241 Keras, 242 OpenCV, 242 Tensorflow, 242 и компьютерное зрение, 241 основы, 241 ссылка, 241 Метапрограммирование, 166 Многонитевость на стороне хоста, 106 Монте-Карло, оценка π, 161 Мультипроцессность на стороне хоста, 106
Д
Н
Г
Данные, подготовка, 197 Динамический параллелизм, 219 использование для QuickSort, 220 Дискретное преобразование Фурье, 147 Дополнение нулями, 152 Дорожка (lane), 226 Доступ к памяти (memory access), 223
И
Игра Конвея «Жизнь», 70 Искусственный нейрон (AN), 181 плотный слой, реализация, 182 Использование NSight под Linux с Eclipse, 128 Использование ассемблера PTX прямо в коде, 228 Использование нескольких контекстов для параллельности на стороне хоста, 107
К
Классификатор, 181 Кластерные вычисления, 238 Код на PTX запуск, 208 компиляция, 208 Компилированный код, запуск при помощи Ctypes, 202 Компьютерное зрение (computer vision), 241 Контексты, 103 Круговая свертка (circular convolution), 150
Л
Латентность (latency), 9
Набор данных Iris, 197 Назначение метод (labeling), 184 Наивный алгоритм префиксной суммы, 82 Нейронные сети, 181 Нитей взаимодействие, 77 Нитей синхронизация, 77 Нити, 70 Нормализации метод, 197 Нормализация, 197
О
Обертки для CUDA Driver API, 209 Окружение для программирования на С++ настройка, 35 настройка Eclipse IDE, 35 настройка GCC, 35 настройка Visual Studio, 36 настройка графических зависимостей в Linux, 35 установка CUDA Toolkit, 38
П
Параллельность на стороне хоста, работа с несколькими контекстами, 107 Параллельный scan, 82 Перестановка в пределах варпа, 225 Плотный слой (dense layer), 181 Подготовка данных (conditioning), 197 Последовательная сеть (Sequential network) градиентный спуск, 193 нормализация данных, 197
Предметный указатель подготовка данных, 197 реализация, 189 реализация методов вывода, 191 Потоки, 91 Потокобезопасные атомарные операции, 224 Поэлементные арифметические операции выполнение с gpuarray, 50 тестирование скорости, 52 Поэлементные вычисления, выполнение при помощи ElementWiseKernel из PyCUDA, 55 Программирование нескольких GPU, ссылка, 238 Профайлер, 25 Профилировка кода, 25 использование модуля cProfile, 25
Р
Разделяемая память (shared memory), использование, 80 Распараллеливаемая задача, 20 Распараллеливание, 20 Регистры, 229 Редукции ядра, 63 Ручное создание контекста, 105
С
Свертка (convolution), 149 Свертки ядро (convolution kernel), 149, 150 Сетки (grids), 70 Сигмоидные функции активации (sigmoid activation functions), 184 Сингулярное разложение (Singular Value Decomposition, SVD), 155
253
использование для анализа главных компонент, 156 Синхронизация текущего контекста, 104 Синхронизация устройства, 92 Слой мягкого максимума, реализация, 187 События (events), 100, 101 Состояние гонки (race condition), 77 Спуск, 193 Стохастический градиентный спуск (batch stochastic gradient descent), 193 Стохастичный (термин), 193
У
Усеченное линейное преобразование (Rectified Linear Unit, ReLU), 184 Ускорение от распараллеливания, 20
Ф
Формат по столбцам (column-major format), 143 Форматирование строки при помощи словаря, 167 Функторы, использование с библиотекой Thrust, 176 Функция потери перекрестной энтропии (cross-entropy loss), 189
Э
Эффективный алгоритм параллельной префиксной суммы, 85 проход вверх, 86 проход вниз, 86 реализация, 87
Я
Ядра, 67
Книги издательства «ДМК Пресс» можно заказать в торгово-издательском холдинге «Планета Альянс» наложенным платежом, выслав открытку или письмо по почтовому адресу: 115487, г. Москва, 2-й Нагатинский пр-д, д. 6А. При оформлении заказа следует указать адрес (полностью), по которому должны быть высланы книги; фамилию, имя и отчество получателя. Желательно также указать свой телефон и электронный адрес. Эти книги вы можете заказать и в интернет-магазине: www.a-planeta.ru. Оптовые закупки: тел. (499) 782-38-89. Электронный адрес: [email protected].
д-р Бриан Тоуманен
Программирование GPU при помощи Python и CUDA Главный редактор
Мовчан Д. А.
[email protected]
Перевод Корректоры Верстка Дизайн обложки
Боресков А. В. Ганюшкина Е. В., Синяева Г. И. Чаннова А. А. Мовчан А. Г.
Формат 70×100 1/16. Гарнитура PT Serif. Печать офсетная. Усл. печ. л. 20,64. Тираж 200 экз. Веб-сайт издательства: www.dmkpress.com