Quantcast
Channel: Найцікавіше на DOU
Viewing all articles
Browse latest Browse all 8115

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

$
0
0

Эта статья подготовлена на основе доклада Андрея Чередарчука и Александра Судакова на Root Linux Conference 2017 — ежегодной конференции embedded- и Linux-разработчиков.

Андрей Чередарчук — ИТ-инструктор и администратор. Сертифицированный инструктор учебных программ HP, IBM, VMware, ранее также и Cisco. Разрабатывает авторские учебные курсы. Занимается поддержкой HPC-инфраструктуры в НАН Украины.

Александр Судаков — глава лаборатории параллельных вычислений, доцент КНУ им. Тараса Шевченко. Одно из основных направлений его научной деятельности — высокопродуктивные вычислительные компьютерные системы. Александр является разработчиком и руководителем вычислительного кластера информационно-вычислительного центра КНУ им. Тараса Шевченко. Принимал участие в создании первых в Украине сайтов Grid-систем.

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

Современные тренды в HPC

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

Классические кластерные системы сегодня массово строятся с использованием графических акселераторов. В связи с этим актуальна задача разработки новых и портирования старых приложений для графических акселераторов. Особенно интересно, чтобы программы, которые ранее были написаны под MPI или OpenMP, могли использовать в том числе и возможности нового аппаратного обеспечения. Например, могли бы одновременно считаться на графических акселераторах и процессорах одного или нескольких серверов.

На чем разрабатывать

Какие сегодня есть фреймворки для разработки под GPU и под процессоры в общей памяти? Во-первых, OpenCL. Эта технология рассчитана на использование как GPU, так и процессоров хоста. Из всех технологий, которые сегодня есть, OpenCL поддерживает наибольшее количество видов аппаратных ресурсов. Но есть мнение (иногда спорное), что программы, написанные под OpenCL, не всегда работают на NVidia GPU так же быстро, как программы, написанные с использованием CUDA.

Название технологии CUDA расшифровывается как Compute Unified Device Architecture. Ее большой недостаток — то, что она поддерживает только устройства Nvidia. С другими устройствами, скажем AMD, она не работает. Но зато большинство вычислительных приложений под NVidia GPU разрабатывается именно с использованием CUDA.

Следующая технология — это OpenMP. Все, кто разрабатывал параллельные программы под общую память на обычных вычислительных серверах, знают, что это такое. Это средство полуавтоматического распараллеливания. Программист указывает компилятору, какие участки кода распараллеливать и выполнять в разных потоках. В OpenMP 4.0 определена поддержка вычислительных акселераторов, но, честно говоря, полной поддержки этого стандарта в существующих компиляторах почти нет.

OpenACC — это еще один фреймворк, который похож на OpenMP и поддерживается некоторыми коммерческими компиляторами. В OpenACC есть набор готовых библиотек для работы с GPU. OpenACC не распространяется свободно.

Если сравнивать CUDA и OpenCl, то у них очень похожая идеология разработки. Однако API у них отличается. Если разобраться с одним, то понять другой потом будет несложно. До появления OpenCL 2.0 главным принципиальным отличием CUDA и OpenCl была поддержка Managed Memory в технологии CUDA. Технологии быстро развиваются и сегодня OpenCl имеет аналог, который называется Shared Virtual Memory. В дальнейшем остановимся более подробно на технологии CUDA. Далее все изображения и термины взяты с сайта Nvidia, где документация для разработчиков под CUDA есть в свободном доступе.

Модель вычислений

Основная терминология CUDA такая: хост — это вычислительный узел, на котором запускается ваша программа. В этот вычислительный узел, у которого есть один или несколько процессоров, вставлен один или несколько графических акселераторов, которые называются девайсами или устройствами. Программа, которая работает на хосте и устройстве, состоит из двух частей: это код хоста, который выполняется на процессорах хоста, и так называемый кернел (Device Kernel), или ядро устройства. Ядро выполняется на графическом акселераторе. Любая программа для GPU имеет и ядро, и код хоста.

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

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

В чем особенность выполнения кернелов на устройстве? В кернелах используется так называемая SIMD-модель (Single Instruction Multiple Data). Это модель массового параллелизма, в которой все потоки (thread) выполняют один и тот же код, но для разных данных. Если у вас есть какой-то массив, то каждый поток обрабатывает один элемент этого массива. При этом набор из нескольких потоков, которые называются варпом (Warp), физически выполняют одну машинную инструкцию.

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

Несколько потоков варпов объединяются в блоки (Block). Разные варпы блока могут выполнять разные машинные инструкции. Главная особенность блока — то, что все потоки одного блока могут использовать общую быструю память. В разных блоках области общей памяти разные. Скорость работы этой памяти такая же, как и скорость работы с регистрами вычислительных элементов. Эта скорость значительно превышает скорость работы с глобальной памятью устройства.

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

То есть в варпе все выполняется одновременно. Блок — почти одновременно. Грид — параллельно или псевдопараллельно, как «захочет» планировщик.

Код кернела

Что собой представляет самый простой код кернела? Простой код кернела может представлять собой вот такую функцию на языке С:

О том, что это кернел говорит слово «global». В эту функцию передаются указатели на массивы данных a, b, c в памяти устройства. Задача кернела — сложить массивы a + b и записать результат в массив c. Все потоки кернела выполняют одинаковые операции сложения и присваивания, но для разных элементов массивов. Для каждого элемента массива в каждом потоке вычисляется свой индекс на основании номера блока, номера потока и размера блока. Разные потоки одновременно складывают разные элементы двух массивов. Это типичная идеология для разработки под CUDA.

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

Код хоста для запуска кернела вызывает вот такую вот функцию: mykernel<<< N_Blocks, N_Threads_Per_Block >>>(a, b, c,);Сюда передается количество блоков (размер грида), количество потоков в блоке (размер блока) и аргументы кернела. Этот синтаксис поддерживается компиляторами CUDA.

Распределенность

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

Если программа хоста выполняется на виртуальной машине, то появляются еще промежуточные этапы передачи данных, поэтому скорость обычно не превышает 4-8 ГБв секунду. Это, конечно, быстрее, чем по локальной сети, но по сравнению со скоростью передачи внутри устройства, это очень медленно. Внутри устройства скорость передачи может быть больше 100 ГБайт/сек. Поэтому если вам удастся написать программу так, что время передачи данных на устройство будет очень маленьким по сравнению со временем обработки, то можно получить очень высокую производительность. Чтобы избежать задержек при передаче данных, необходимо максимально кэшировать данные на хосте и устройстве.

Память

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

Глобальная и локальная память — это относительно медленная память (обычно DDR4 или DDR5). В этой памяти выделяются области константной памяти, текстурной памяти и в зависимости от computing capability — L2-кэш. L1-кэш есть у всех устройств.

Вторая особенность — на GPU объем памяти значительно меньше, чем на хосте. Это один из недостатков, с которыми приходится сталкиваться. Ниже показаны примеры, как можно при написании своего кода указать, какую память вам нужно использовать. Ключевое слово «global» относится к кернелам, т. е. у кернела указывается ключевое слово «global» при написании кода. Если указано ключевое слово «device», то это значит, что эти данные должны находиться в глобальной памяти. Т. е. вы можете явно указать, где хранить ваши данные на GPU. «Constant» означает, что данные будут храниться в константной памяти. Это read-only память, которая кэшируется в L1 кэше. Изменять эти данные нельзя, они задаются на этапе компиляции или на этапе запуска ядра. «Shared» означает, что данные должны храниться в быстрой общей памяти блока. Ниже указаны типичные размеры областей памяти, с которыми приходится иметь дело.

__global__ void mykernel(int)
– Код кернела
__device__ float data;
– Данные в голобальной памяти
__constant__ float data [<=64 Kbytes];
- Константа в кешируемой глобальной памяти
__shared__ float data[<=48 Kbytes];
– Общая память для блока – очень быстрая!
Текстурная память
– Кэшируется в L1 , редко используется для GPGPU
__restrict__ float *pdata
– Только чтение! Кэширование в L2
Включение/выключение кэширования при компиляции
- Кэшировать глобальную память в L1 –Xptxas –dlcm=cg
может быть SEGFAULT!
- Размер L1, общей памяти, блока регистров –maxrregcount=N
- cudaFuncSetCacheConfig Размер кэша/общей памяти
Локальная память – стек в глобальной памяти GPU, медленно
- Маленький объем, кэшируется в L1

Текстурная память для расчетов сейчас уже не так актуальна. В старых версиях GPU текстурную память можно было использовать как быструю кэшируемую память. Сейчас она больше актуальна для обработки 3D-графики. Ключевое слово «restrict» означает, что данные хранятся в L2-кэше в read-only режиме. Если у вас какие-то структуры данных часто используются как read-only, вы можете указать компилятору, чтобы он обращал на это внимание и сгенерировал более эффективный код. Также можно включать и отключать кэширование глобальной памяти в L1-кэше, но это поддерживают не все GPU.

Дело в том, что если у вас GPU, скажем, Tesla с computing capability 3,5 и выше, то там такое кэширование поддерживается. Если же у вас GeForce с той же computing capability 3,5 — то там такого кэширования нет. С L1 кэшированием глобальной памяти нужно быть осторожным. Так как L1-кэш свой для каждого вычислительного процессора GPU, иногда возникают проблемы с общими структурами данных в глобальной памяти при кэшировании в L1. L2-кэширование глобальной памяти работает по-умолчанию, если доступно. На этапе компиляции можно изменять количество регистров, и даже на этапе выполнения можно изменять соотношения между общей памятью, глобальной памятью и L1-кэшем.

Следующий этап — память нужно выделить. Графический акселератор — это отдельное устройство. Если вы пишете программу, то нужно четко понимать, где выделяется память — в оперативной памяти хоста или где-то на устройстве — и как копировать данные с хоста на графический акселератор и обратно. В чем тут может быть проблема? Если программа разрабатывается «с нуля», все достаточно просто. Если же вы портируете существующую программу, в которой уже есть сложные структуры данных, то скопировать их с одного устройства на другое или, хуже того, с устройства с одной архитектурой на устройство с другой архитектурой — проблема. Если этим приходится заниматься, то нужно использовать все возможности работы с памятью.

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

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

Для работы с памятью существует такая подсистема, как Unified Virtual Address Space (UVA). Это программно-аппаратная технология, которая позволяет очень сильно облегчить жизнь программисту. Фактически память хоста и память устройства видится как одна общая память. Что UVA позволяет делать? Она позволяет прозрачно для программиста отображать память с хоста на устройство и с одного устройства на другое. Таким образом, программисту нет необходимости выполнять сложное копирование данных с хоста на устройство и обратно. Можно просто заполнить массив структур, связанный список, или хэш-таблицу на хосте, отобразить ее на устройство и там сразу же использовать. Но есть ряд нюансов.

Первый вариант использования UVA это — zero-copy pinned memory. Это очень быстрый и удобный способ. Единственная проблема: адрес отображенной памяти на устройстве не совпадает с адресом памяти на хосте. Поэтому если в памяти массив данных типа float — то проблем нет. Если в памяти структура данных с указателями — проблемы есть. Как это можно использовать? Объявляем массив на хосте, отображаем этот массив на память устройства, получаем адрес на устройстве и запускаем кернел с этим адресом в качестве параметра.

Еще один инструмент, который есть в CUDA (и которого совсем недавно не было, но уже есть в OpenCL) — это managed memory. Это еще более удобная штука. При использовании managed memory даже указатели сохраняются. То есть адрес памяти на хосте и адрес памяти на устройстве при отображении будет один и тот же. В сложной хэш-таблице с указателями все указатели на хосте и на устройстве будут одинаковыми и будут одинаково работать. Это очень удобно, но гарантированно будет работать, если кернел не обращается к отображаемой памяти одновременно с кодом хоста.

Опять же, жизнь не стоит на месте, и начиная с Nvidia Pascal (computing capability 6) и CUDA-8, поддерживается параллельное использование managed memory кодом хоста и устройства, однако для более старых устройств все остается по старому! Т. е. вы на хосте заполнили массив, запустили кернел, хост подождал, кернел посчитал — хост забрал результаты.

Если вы отобразили массив как managed memory, то при попытке хоста записать одну его часть, а устройства — другую, возникнет ошибка. Конечно, есть возможность «переотобразить» части данных для использования хостом или устройством, но это неудобно и медленно. Поэтому если программа пишется под десктоп, где один слабый процессор или программа работает в клауде под виртуализацией на слабых процессорах, но есть GPU, то managed memory — это спасение для тех, кто хочет портировать свои программы на CUDA и получить высокую производительность.

Как Nvidia рекомендует использовать managed memory в структурах данных? Определяется класс, в котором есть оператор newи оператор delete, выделяющие и освобождающие память с использованием managed memory.

// class for transparent allocations
struct cuda_mapped {
void *operator new(size_t len) {
void* ptr;
cudaMallocManaged(&ptr, len);
cudaDeviceSynchronize();
return ptr;
}
void operator delete(void *ptr) {
cudaDeviceSynchronize();
cudaFree(p);
}
…
};
// allocated at device and host
struct complicated: public cuda_mapped {
complicated* next;
…
void func(){
next = new complicated;
}
};

Оператор newвызывает API-функцию cudaMallocManaged, оператор deleteвызывает API-функцию cudaFree. После этого все необходимые классы объявляются производными от этого класса, и дальше оператор newи оператор deleteбудут прекрасно работать и прозрачно использовать managed memory (если, конечно, структур данных не слишком много, так как объем managed memory ограничен). Можно с помощью препроцессора (#ifdef __CUDACC__) включать и выключать наследование ваших классов от cuda_mapped в зависимости от того, поддерживает ваш компилятор CUDA или нет. Больше ничего в структурах данных менять не нужно — структуры данных портируются очень просто.

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

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

template <typename T>
int copy_type_to_cuda(T* cuda_to, T* host_from ){
cudaMemcpy(cuda_to,host_from,sizeof(T),cudaMemcpyHostToDevice) ;
}
-----------------------------------------------------------------------
template <typename T>
int copy_vector_to_cuda(T* to, T* from){
typename T::value_type **p = (typename T::value_type**)to;
if(from->size()){
cudaMalloc(to,from->size()*sizeof(typename T::value_type) );
p[1] = p[0]+from->size(); p[2] = p[1];
cudaMemcpy(p[0],from->data(),from->size()*sizeof(typename
T::value_type),cudaMemcpyHostToDevice) ;
} else { p[0]=p[1]=p[2]=NULL;}
}
--------------------------------------------
template <typename T>
int copy_pointers_vector_to_cuda(T* to, T* from){…}

Первая функция — это шаблонная функция copy_type_to_CUDA, которая прозрачно копирует объект любого типа с хоста на устройство. На устройстве должна быть выделена память, адрес которой передается в качестве первого аргумента, а адрес объекта на хосте — в качестве второго. Однако такой функции недостаточно, если нужно скопировать тип данных с динамически выделяемой памятью на устройство, например, stl вектор. Такое тоже приходится делать, если stl вектора массово используются в программе.

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

Стримы и планирование

Еще одна интересная вещь, которую предоставляет CUDA, — асинхронное планирование с использование CUDA streams. Кто запускал задачи на кластере в batch режиме хорошо знает, что такое очередь, планирование и синхронизация между очередями заданий. Фактически CUDA streams — это набор очередей для выполнения заданий, таких как копирование данных, запуск кернелов и др. Есть дефолтный стрим 0 — это синхронный стрим, куда все действия ставятся в очередь по умолчанию и выполняются последовательно в порядке записи в синхронном режиме.

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

Использование CUDA streams — достаточно удобная ведь для того, чтобы получить максимальную производительность вашей программы как на хосте, так и на устройстве. Если у вас была MPI-программа, то вместо MPI send или MPI receive можно использовать CUDA streams. Т. е. отправка данных на устройства, запуск кернела и так далее. Синхронизация между стримами выполняется с помощью ивентов (событий). При синхронном выполнении вы сначала копируете данные на устройство, потом запускаете кернел, а потом копируете данные обратно.

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

Ниже показан пример планировщика, который использовался при портировании программы, ранее написанной в стандарте OpenMP для одновременной работы на GPU и CPU.

#pragma omp parallel for
for(int i=-gpus_num; i<host_data_size; i++){
if(i<0) start_gpu_job(i);
else start_host_job(i)
}
if(gpus_finish_job()) increase_gpu_job();
else increase_host_job();
wait for gpus();

Этот код содержит цикл по всем данным хоста, которые мы хотим обработать. Строка #pragma omp parallel for — это директива компилятора для автораспараллеливания цикла на несколько потоков хоста. В оригинале цикл начинался с нуля и заканчивался общим количеством данных. После портирования цикл начинается с отрицательного числа, которое по модулю равно общему количеству акселераторов в системе и заканчивается количеством данных, которое обрабатывает хост. Для отрицательных значений параметра цикла запускается обработка данных на GPU, для положительных — вычислительный поток хоста. Операции выполняются параллельно. После завершения цикла проверяется, какая работа закончилась раньше — устройств или хоста, и соответственно корректируется объем данных, с которыми работают хост и каждое из устройств.

Несколько акселераторов

Нет ничего сложного в использовании нескольких GPU. Нужно определить, сколько у вас устройств, какие их характеристики и годятся ли они для вашей задачи. Это можно сделать с помощью функций cudaGetDeviceCountи cudaGetDevice. Перед запуском операций на какое-либо устройство вызывается функция cudaSetDevice, в которую передается номер устройства. Главное, это нужно не забывать делать перед созданием стримов, перед выделением памяти, перед запуском кернела и т. д. Компиляторы CUDA дают возможность сгенерировать код для устройств с различными значениями computing capability и скомпоновать этот код в одной программе, которая будет работать с разными устройствами. Главное, не забыть указать соответствующие опции компилятора.

Компиляторы и библиотеки

На чем и как писать код? В фреймворке CUDA поддерживаются языки C и C++. Некоторые коммерческие компиляторы, как от PGI или от IBM, поддерживают язык Fortran. При отсутствии компилятора, который поддерживает написание кернелов на языке Fortran, можно написать кернел и его вызов на C или C++ и скомпоновать с программой на языке Fortran. Код, который компилируется для устройства и хоста, обозначается ключевыми словами __host__ и __device__ соответственно. Можно с помощью препроцессора определять разный код, который будет компилироваться или только для хоста, или только для устройства.

Далеко не все функции, которые написаны для хоста нужно портировать на устройство. Например, если в классе есть функция «прочитать конфигурационный файл», то она нужна на хосте, а в кернеле совершенно не нужна. Главная проблема портирования кода состоит в том, что для устройства нет всех тех библиотек, которые есть для хоста. Если программа использует, например STL, то для того, чтобы ее портировать для выполнения на устройстве, нужно или переписать код без STL, или реализовать те функции STL, которые вам нужны самостоятельно. В CUDA нет реализации STL для использования в кернелах. Еще больше проблем возникает при необходимости копирования полиморфных объектов, которые создаются на хосте, но вызывать виртуальные методы для них нужно на устройстве. Хотя эту задачу тоже можно решить.

Эффективность

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

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

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

Живой пример

В качестве примера кода, который был успешно портирован с процессоров общего назначения на GPU, можно привести программу авторов для расчета динамики сложных биологических нейросетей от сотен тысяч до сотен миллионов нейронов. С помощью GPU и этой программы впервые удалось промоделировать динамику трехмерных систем в модели Курамото-Сакагучи из примерно 100 000 000 нейронов и открыть новые типы состояний больших связанных систем [Link 1, Link 2].

Графический акселератор GeFroce GT 640, достаточно недорогое устройство, для этой задачи показал производительность примерно равную производительности 12-ядерноговычислительного узла с процессорами Intel Xeon 2620. Примерно такую же производительность показали и более новые энергоэффективные акселераторы Quadro K620. Производительность Tesla K40 (не новый, но дорогой и производительный акселератор) соответствует примерно 50 таким процессорным ядрам, то есть этот акселератор заменяет кластер из 4-хтаких узлов [Link 1].

Для портированного кода удалось достичь примерно 60% пиковой производительности Tesla (порядка 600 гигафлопсов). Для GPU это очень неплохо. На CPU программа использовала 75-80%пиковой производительности вычислительного узла.

Выводы

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

Главные недостатки — не очень простое портирование кода, отсутствие библиотек и маленький объем памяти.


Viewing all articles
Browse latest Browse all 8115

Trending Articles