В далёком 2009 году мы проходили стажировкув нижегородском отделении Intel'а, в суровой команде суровых TBB-шников. После полутора месяцев активного изучения возможностей Intel TBB у нас появилась идея сделать нечто похожее, но сразу для связки CPU+GPU. Потребовалось порядка трёх лет - и вот, компания ttgLabs, LLC представляет на выставке ISC-2012 бета-версию своего продукта TTG Apptimizer. Который получился действительно похож на TBB. Но только ещё и для GPU.
Основной идеей TTG Apptimizer является набор гибридных примитивов, изрядно сдобренных хитрым планировщиком динамической балансировки нагрузки. От программиста требуется написать вычислительные ядра на любом поддерживаемом API (будь то CUDA, OpenCL или просто C++ с, например, директивами OpenMP), обернуть их в подходящий примитив, и ... всё. Дальше при вызове примитива наша библиотека автоматически «растащит» подходящие программные ядра по всем найденным вычислителям, которыми могут быть как ставшие уже обыденными CPU и GPU, так и новомодные APU и iGPU.
Проиллюстрировать эту идею лучше всего на примере. В Intel TBB есть такой примитив как parallel_for. У нас же его прообраз называется HybridFor и работает следующим образом:
void cudaKernel(void *data, size_t lo, size_t hi) { /*Processing data using CUDA*/ } void clKernel(void *data, size_t lo, size_t hi) { /*Processing data using OpenCL*/ } void cudaKernel(void *data, size_t lo, size_t hi) { /*Processing data using SSE*/ } HybridFor hFor; hFor.CUDA() += cudaKernel; hFor.OpenCL() += clKernel; hFor.Serial() += sseKernel; double data[1024]; hFor.Process(data, 1024);
В этом простом примере мы обернули три функции в ядра, которые будут использованы примитивом для обработки массива dataиз 1024 элементов. Причём сама обработка будет идти параллельно на всех доступных устройствах. Так, если в системе «обнаружится» ускоритель с поддержкой CUDA, то для него будет использована либо функция cudaKernel(), либо функция clKernel(). Причём автоматически будет выбрана та, которая работает быстрее на массивах размером 1024. А если эту же программу без перекомпиляции перенести на систему только с процессором поколения Ivy Bridge, то часть данных будет обработана на iGPU с помощью OpenCL, а другая часть - на CPU.
Примерно на этом месте у опытных программистов начинают возникать вопросы - «Стоп! А как реализована работа с памятью?». Ответ пока простой, копировать данные нужно руками. То есть наша библиотека явно говорит - обработать от сих до сих (точнее, передаёт соответствующие значения в качестве аргументов), после чего программист должен копировать нужный участок в память устройства, обработать его и, при необходимости, перекопировать обратно. Очевидно, что из-за медленной шины PCI-Ex подобная схема напрочь убивает производительность. Но с TTG Apptimizer это не страшно - если при попытке таким образом «распараллелить» вычисления всё стало хуже, то библиотека это поймёт будет выполнять расчёты только на процессоре. Или только на одном GPU. А может и на двух - всё зависит от специфики как самих данных, так и вычислителей.
Собственно, здесь и проявляется наша ключевая особенность, она же «фича» - система динамической адаптации вычислений. Библиотека «следит» за работой программы и по ходу подстраивает её как под найденные вычислители, так и под обрабатываемые в настоящий момент данные. Таким образом в вышеприведённом примере неприлично маленький массив будет обработан только один ядром процессора, массив побольше - всеми ядрами CPU, а для массивов среднего размера уже будет задействован GPU. Более того, если в самом вычислительном ядре также есть возможность что-либо динамически «подкрутить» (размер блоков, гранулярность параллелизма, всевозможные threshold'ы), то можно поручить определение их оптимальных значений TTG Apptimizer'у. Например, как в следующем коде:
Parameter sseBlockSize = 32; void sseKernel(void *data, size_t hi, size_t lo) { for (…) for (int i = 0; i < sseBlockSize; i++) //Computing. }
Начиная с некоторого момента значение «константы» sseBlockSizeволшебным образом изменится так, чтобы обеспечить максимальную производительность. Например, за счёт лучшего попадания блоков в кэш. Естественно, сама программа об этом ничего может и не узнать.
И теперь немного про то, как же это работает «изнутри». Итак, мы рассматриваем работу программы как некий итерационный алгоритм. Если это научные вычисления, конвертация видео, 3D-игра, то одна итерация - это шаг по времени / обработанный кадр. Если же в программе нет чёткой итерационной структуры (например, плагин для Photoshop'а), то одной итерацией является один запуск программы. Далее, благодаря механизму шаблонов языка C++, мы имеем доступ к любой оптимизируемый переменной, объявленной с помощью шаблонного класса Parameter. Собственно, здесь и появляется ещё одна Основная Идея - а давайте-ка всё это «скормим» какому-нибудь классическому методу оптимизации. Так, оптимизируемой функцией будет время одной итерации, её аргументами - доступные переменные, одним замером - прогонка одной итерации. Итог: программирование заканчивается, теорема Кун-Таккера начинается!
Собственно, большая часть TTG Apptimizer'а как раз и является развитием попытки «скормить» озвученные данные популярным методам оптимизации - покоординатному спуску, генетическому алгоритму, а также ряду других. Правда, оказалось, что просто так эти алгоритмы неприменимы - нужно активно использовать априорную информацию о системе. А сама оптимизируемая функция не является статичной и любит изменяться от замера к замеру. И ещё некоторые значения её аргументов могут привести к «падению» программы. Ах да, количество доступных замеров крайне мало …
В общем, проблем оказалось также много. Однако, после их успешного решения всё стало работать. Даже более - работать быстрее, чем без динамической адаптации. И чтобы не быть голословными, рассмотрим результаты одного из демонстрационных примеров из нашего SDK:
Здесь решается трёхмерное эллиптическое уравнение на структурированной сетке на одном GPU от … скажем так, одного производителя GPU (подсказка - не Intel). Синяя линия - неоптимизированная версия программы, красная - оптимизированная (с использованием shared memory), жёлтая - на самом деле либо синяя, либо красная, но в которых 3 параметра «подкручиваются» с помощью TTG Apptimizer. С точки зрения программиста отличие жёлтой линии от остальных заключается в 10-15 строчках кода. А производительность, как легко заметить, в среднем на 25% выше.
Если рассматривать проблемы балансировки нагрузки, о которых упоминалось в самом начале, то можно взять другой пример - решение модельной задачи N-тел. На этот раз тесты «гоняются» на системе с двумя GPU. А сравнивается «статическая» балансировка (в аналогии с тем как делается в GPGPU-реализации Linpack'а) с одной из наших динамических.
Как видно, динамической балансировке не страшна никакая гетерогенность - когда от использования отдельных вычислителей становится только хуже, они перестают использоваться. Что, например, отчётливо видно на системах с количество тел до 1024 - там ускорение составило до 20 раз благодаря отказу от GPU. А на больших данных (16k и более) пришлось отказаться уже от CPU, он со своими 33 «реальными» GFlops'ами только всё тормозил. Правда, выигрыш оказался более скромным - порядка 10%, но на данном железе больше и не «выжать». Также стоит отметить, что отличия красной линии от синей заключаются всего в нескольких десятках строк кода. Тех самых, которые были в примере про HybridFor.
В заключение также стоит сказать пару слов про текущее состояние TTG Apptimizer. По нашим меркам - это вполне стабильная бета. По меркам Intel, скорее всего, сойдёт за альфа-версию. В дальнейшем мы планируем «допилить» полноценную поддержку OpenCL и MPI, а также апробировать его 2-3 сторонних проектах. А ещё дальше … ждите на всех суперкомпьютерах России.
Ну и если вдруг ещё есть читатели, дошедшие до этих строк - текущую версию документации можно получить по этим ссылкам (1, 2), а сам SDK - написав на почту mail@ttgLabs.com.