ATI Stream SDK было переименовано в AMD Accelerated Parallel Processing (APP) SDK, на смену основного языка программирования GPGPU вычислений Brook+ пришел OpenCL. Однако, не многие догадываются, что писать код для ATI-шных карточек можно при помощи другой технологии: AMD Compute Abstraction Layer (CAL) / Intermediate Language (IL). Технология CAL предназначена для написания кода, взаимодействующего с GPU и выполняющегося на CPU, в то время как технология IL позволяет писать код, который будет выполняться непосредственно на GPU.
В данной статье будет рассмотрена технология IL, ее область применения, ограничения, преимущества по сравнению с OpenCL. Кому интересно, прошу под кат.
Введение
Для начала, приведу некоторые сравнения с Nvidia CUDA SDK:
- Язык программирования высокого уровня:
- Nvidia: CUDA C++ Extension
- AMD: OpenCL 1.1 либо Compute Abstraction Layer (CAL)
- Язык программирования низкого уровня (псевдо ассемблер*):
- Nvidia: Parallel Thread Execution (PTX)
- AMD: Intermediate Language (IL)
- Соотношение «количество попугаев в секунду» (к примеру, количество перебираемых хешей в секунду) к «цене GPU»:
- Nvidia: x
- AMD: ~2x при использовании связки CAL/IL
* означает, что язык хоть и похож на ассемблер, он все же оптимизируется компилятором и преобразовывается в разный код для разных GPU
За счет чего же можно получить такой выигрыш в производительности?
Особенности архитектуры AMD GPU
Если внимательно почитать спецификацию Nvidia PTX и спецификацю AMD IL, то можно заметить, что операнды в Nvidia PTX являются однокомпонентыми векторами (то есть простыми n-битными регистрами), в то время как операнды AMD IL являются 4-х компонентыми векторами n-битных регистров. Это станет более понятно, если рассмотреть операцию умножения в обоих языках:
# Nvidia PTX mul.u32 %r0, %r1, %r2 # AMD IL umul r0.xyzw, r1.xyzw, r2.xyzw
Таким образом, за одну (почти за одну) операцию AMD GPU может изменить вплоть до 4-х n-битных регистров, а Nvidia GPU — только один n-битный регистр (имеется в виду в пределах одного GPU-потока). Но ведь OpenCL также позволяет объявлять многокомпонентные вектора и работать с ними! Тогда в чем отличие и зачем вообще нужен этот IL?
Отличие от OpenCL
А все отличия заключаются банально в том, что разработчикам AMD APP SDK было либо сложно, либо технически невозможно создать компилятор, переводящий код, написанный по спецификации OpenCL, в код, написанный на AMD IL. Отсюда и возникли ограничения по поддержке стандарта OpenCL:
- OpenCL 1.0 поддерживается начиная примерно с Radeon HD 4000 Series (Beta Level Support) (возможно, отсутствует поддержка image object, т.е. текстурной памяти)
- OpenCL 1.1 поддерживается начиная примерно с Radeon HD 5000 Series
- OpenCL 1.2 поддерживается начиная примерно с Radeon HD 7000, но при этом еще даже не вышло SDK, поддерживающее эту версию стандарта
При этом стоит отметить, что AMD IL позволяет использовать для GPGPU-вычислений некоторые карточки из Radeon HD 3000 Series и даже из Radeon HD 2000 Series! (если быть совсем точным, то это GPU на чипах R600, RV610, RV630 и RV670)
Далее для краткости будем обозначать все GPU, начиная с Radeon HD 5000 Series, как Evergreen GPUs (это чип Radeon HD 5700), потому что только для этих карточек поддерживаются некоторые интересные операции.
Прежде чем перейти к объяснению принципов написания кода на AMD IL, я бы хотел заострить ваше внимание на
Особенности работы с памятью
Как я уже упоминал, AMD GPU работает с 4-х компонентыми векторами n-битных регистров, где n=32 (о том, как работать с 64-битными регистрами, далее). Это накладывает основное ограничение на память: выделять память можно только объемом, кратным 16 байтам. При этом нужно помнить, что при загрузке дынных из памяти минимальным объемом передачи являются опять же эти 16 байт. То есть совершенно неважно, укажите вы, что ваша память состоит из 4-х компонентых векторов по 1 байту (char4), что из 4-х компонентых векторов по 4 байта (int4), результат будет один — из памяти за одну операцию обмена загрузятся 16 байт.
Далее, в отличие от Nvidia GPU, AMD GPU выделяет локальную память в глобальной области (а это означает очень медленную скорость обмена данных), так что забудьте про локальную память. Используйте регистры и глобальную память.
И на последок: опять же в отличие от Nvidia GPU, есть только одна глобальная память, работающая на чтение-запись (далее это будет «g[]»), и много различных источников текстурной памяти (далее это будет «i0», «i1» и т.д.) и константной памяти (далее это будет «cb0», «cb1» и т.д.), работающих только на чтение.
Особенностью константной памяти является наличие кеширования при доступе всеми GPU-потоками к одной области данных (работает также быстро, как и регистры).
Особенностью текстурной памяти является кеширование чтения (от 8 КБ, если мне не изменяет память, в расчете на один потоковый процессор) и возможность обращения к памяти по вещественным координатам. При выходе за границы текстуры можно либо считывать граничный элемент, либо закольцовываться и считывать сначала (координата берется по модулю ширины/длины текстуры).
А теперь приступим к самому интересному:
Структура кода для AMD IL
Работа с регистрами
Сперва небольшое пояснение, как происходит обмен между регистрами в операциях.
Выходной регистр на месте компоненты вектора может содержать либо имя компоненты, либо знак "_", что означает, что данная компонента не будет изменена.
Каждый входной регистр на месте каждой компоненты может содержать любое имя из четырех компонент, либо «0», либо «1». Это означает, что в операции над соответствующей компонентой выходного регистра участвует либо компонента входного регистра, либо константа. Поясню сказанное на примере:
# r0.x = r1.z # r0.y = r1.w # r0.w = r1.y mov r0.xy_w, r1.zwyy # r0.y = 1 # r0.z = 0 mov r0._yz_, r1.x100
Шейдеры
Код для AMD GPU оформляется в виде шейдеров. Есть возможность запускать как компьютерный шейдер (Compute Shader, CS), так и пиксельный шейдер (Pixel Shader, PS). Однако CS поддерживается, начиная только с Radeon HD 4000 Series. При этом скорость их работы почти одинаковая.
Известно, что количество одновременно запускаемых потоков на GPU определяется параметрами запуска: количество блоков, количество потоков на блок. Каждый мультипроцессор (от 8 штук) GPU берет на исполнение один блок. Затем делит запрошенное количество потоков на блок на куски (warp, кратно 32) и отдает каждому своему поточному процессору на исполнение один warp. Таким образом, реальное количество одновременно работающих потоков равно:
<multiprocessors_count> * <stream_processors_per_multiprocessor_count> * <warp_size>
Именно поэтому для наиболее быстрой работы требуется, чтобы в рамках одного warp'a потоки выполняли одну и ту же операцию, без ветвлений. Тогда эта операция выполнится за один раз.
Для того чтобы не рассматривать сферического коня в вакууме, рассмотрим простую задачу: каждый поток вычисляет свой локальный идентификатор в пределах блока (32 бита), глобальный идентификатор (32 бита), считывает константы (64 бита) из памяти команд и из памяти данных, считывает элемент из текстуры (128 бит). Все это он записывает в выходную память, каждому потоку для этого потребуется 256 бит.
Примечание: каждая строка текстуры содержит данные для потоков одного блока.
Pixel Shader
il_ps_2_0 ; Константный буфер (cb0): ; cb0[0].x - искомая константа ; cb0[0].y - количество потоков ; cb0[0].yzw - мусор dcl_cb cb0[1] ; Входная текстура с данными (i0) ; тип текстуры - двумерная (обращение по двум координатам), ненормированная (иначе значения были бы типа float от 0 до 1) ; всем компонентам нужно задать один и тот же тип (в нашем случае uint) dcl_resource_id(0)_type(2d,unnorm)_fmtx(uint)_fmty(uint)_fmtz(uint)_fmtw(uint) ; Это нужно для получения идентификатора потока dcl_input_position_interp(linear_noperspective) vWinCoord0.xy__ ; Выходной буфер (g[]) ; Константа, хранится в памяти команд dcl_literal l0, 0xFFFFFFFF, 0xABCDEF01, 0x3F000000, 2 ; Считываем вещественные координаты потока с преобразованием в целочисленные ; r0.x - координата по x для i0 для потока (float) (равна локальному идентификатору потока в блоке) ; r0.y - координата по y для i0 для потока (float) (равна глобальному идентификатору блока) ftoi r0.xyzw, vWinCoord0.xyxy ; Вычисляем r0.z - глобальный идентификатор потока (uint) umad r0.__z_, r0.wwww, cb0[0].yyyy, r0.zzzz ; Сохраняем первую часть данных в регистр ftoi r1.x___, vWinCoord0.xxxx mov r1._y__, r0.zzzz mov r1.__z_, cb[0].xxxx mov r1.___w, l0.yyyy ; Вычисляем смещение для выходного буффера g[] umul r0.__z_, r0.zzzz, l0.wwww ; Сохраняем первую часть данных в память mov g[r0.z+0].xyzw, r1.xyzw ; Загружаем данные из текстуры i0 ; предварительно переводим координаты во float и прибавляем 0.5 itof r0.xy__, r0.xyyy add r0.xy__, r0.xyyy, l0.zzzz sample_resource(0)_sampler(0)_aoffimmi(0,0,0) r1, r0 ; sample_resource(0) - читаем из i0 ; _sampler(0) - с помощью sampler'a #0 ; _aoffimmi(0,0,0) - смещения по x, y, z ; если нужно считать соседний элемент в строке текстуры, то _aoffimmi(1,0,0); в столбце - _aoffimmi(0,1,0) ; Сохраняем вторую часть данных в память mov g[r0.z+1].xyzw, r1.xyzw ; Выход из главной функции endmain ; Завершение кода программы end
Compute Shader
Все отличие будет заключаться только в вычислении идентификаторов потока, остальное все то же самое.
il_cs_2_0 dcl_num_thread_per_group 64 ; Константный буфер (cb0): ; cb0[0].x - искомая константа ; cb0[0].yzw - мусор dcl_cb cb0[1] ; Входная текстура с данными (i0) ; тип текстуры - двумерная (обращение по двум координатам), ненормированная (иначе значения были бы типа float от 0 до 1) ; всем компонентам нужно задать один и тот же тип (в нашем случае uint) dcl_resource_id(0)_type(2d,unnorm)_fmtx(uint)_fmty(uint)_fmtz(uint)_fmtw(uint) ; Выходной буфер (g[]) ; Константа, хранится в памяти команд dcl_literal l0, 0xFFFFFFFF, 0xABCDEF01, 0x3F000000, 2 ; номер блока mov r0._y__, vThreadGrpIDFlat.xxxx ; номер потока в блоке mov r0.x___, vTidInGrpFlat.xxxx ; глобальный номер потока mov r0.__z_, vAbsTidFlat.xxxx ; Сохраняем первую часть данных в регистр mov r1.x___, vTidInGrpFlat.xxxx mov r1._y__, vAbsTidFlat.xxxx mov r1.__z_, cb[0].xxxx mov r1.___w, l0.yyyy ; Вычисляем смещение для выходного буффера g[] umul r0.__z_, r0.zzzz, l0.wwww ; Сохраняем первую часть данных в память mov g[r0.z+0].xyzw, r1.xyzw ; Загружаем данные из текстуры i0 ; предварительно переводим координаты во float и прибавляем 0.5 itof r0.xy__, r0.xyyy add r0.xy__, r0.xyyy, l0.zzzz sample_resource(0)_sampler(0)_aoffimmi(0,0,0) r1, r0 ; sample_resource(0) - читаем из i0 ; _sampler(0) - с помощью sampler'a #0 ; _aoffimmi(0,0,0) - смещения по x, y, z ; если нужно считать соседний элемент в строке текстуры, то _aoffimmi(1,0,0); в столбце - _aoffimmi(0,1,0) ; Сохраняем вторую часть данных в память mov g[r0.z+1].xyzw, r1.xyzw ; Выход из главной функции endmain ; Завершение кода программы end
Различия шейдеров
Кроме поддержки на разных карточках, основное отличие шейдеров заключается в месте хранения количества запускаемых потоков на блок. Для PS это значение можно хранить в памяти, для CS это значение нужно пробивать в коде. Кроме того, для CS проще вычислять идентификаторы потока.
Заключение
Я попытался рассказать в данной статье, как написать на AMD IL простой код для выполнения на самой GPU. В качестве заключения несколько слов об оптимизации скорости работы:
- Не пытайтесь применять техники оптимизации, свойственные для ассемблера (предвычисление операций с константами, перестановка независимых операций). Не забывайте, что это все же псевдо ассемблер, поэтому оптимизацию за вас сделает компилятор. Лучше подумайте над алгоритмом.
- Загружайте на карточку как можно больше данных. Желательно использовать все 32 бита всех 4-х компонент вектора.
- Если у вас есть однотипные вычисления над входными данными (к примеру, вычисление хеша), то стоит поэкспериментировать над количеством компонент в операциях: иногда быстрее будет работать r0.x___, иногда r0.xy___, а иногда r0.xyzw.
- Хоть AMD и утверждает, что количество потоков в блоке может быть любым, кратным <warp_size> и при этом GPU будет корректно себя вести, на самом деле это не так. В природе я видел только <warp_size>=32 или 64, и у меня GPU работала корректно только при количестве потоков в блоке, равном <warp_size>. Более того, Radeon HD 4650 при запуске с 32 потоками в блоке (а по техническим данным, для этой карточки <warp_size>=32) на одном из моих алгоритмов выдавала некорректные данные, зато с 64 потоками в блоке работала на ура. Вывод: запускайте алгоритм только с 64 потоками в блоке (а количество блоков уже можно варьировать).
- GPU Evergreen поддерживают несколько прикольных особенностей: циклический сдвиг, поддержку флагов переполнения, поддержку 64-битных операций (для этого резервируются 2 компоненты). К сожалению, GPU семейства младше Evergreen все эти плюшки не поддерживают. Если кто подскажет, как на них написать 64-битные операции, буду признателен.
О том, как же передавать данные на карточку и забирать данные с нее, я попытаюсь рассказать в следующей статье про AMD Compute Abstraction Layer (CAL).
Ссылки для ознакомления
Автор: BrainHacker