Нативный ассемблер для видеокарт Nvidia

Тема в разделе "WASM.ASSEMBLER", создана пользователем Leopard, 9 май 2023.

  1. Leopard

    Leopard New Member

    Публикаций:
    0
    Регистрация:
    17 июн 2021
    Сообщения:
    14
    В архиве из утечки Nvidia есть файлы типа nvasm.exe, который делает cubin файлы из так сказать нативного ассемблера и даже файлы такого вида как nvasm_internal.exe, то есть для внутреннего использования, но nvasm, а тем более nvasm_internal нет в официальном ToolKit от Nvidia. Так что эта вещь получается очень интересная чтобы ее использовать. Даже если ничего не знать про синтаксис, то можно готовый файл с разрешением '.cubin' сначала дизассемблировать с помощью nvdisasm.exe, а потом уже заново скомпилировать nvasm'ом в сравнении с ptx псевдоассемблером, который на порядок выше по стеку и дает намного меньше возможностей в плане контроля используемой постоянной памяти, регистров и так далее, а также использовать команды GPU, не доступные при написании кода ядра на ptx.
     
    MaKsIm и Mikl___ нравится это.
  2. Leopard

    Leopard New Member

    Публикаций:
    0
    Регистрация:
    17 июн 2021
    Сообщения:
    14
    Cubin файл представляет из себя elf формат исполняемых файлов под linux, который немного изменен, а точнее расширен. Внутри находятся главный заголовок, подзаголовки, секции кода для GPU, инициализированных и не инициализированных данных, а также специальная информационная секция и даже секции отладки и перемещений именно для GPU кода. Естественно это код для GPU, а не CPU, причем для каждого ядра (функции) создаются свои отдельные секции инициализированных данных, кода и информации о конкретном ядре. В главном заголовке можно найти версию Toolkit'a, который использовался для компиляции кода ядер GPU и номер архитектуры видеокарты для которой собран этот cubin файл, а также режим адресации памяти (32 или 64 бита). Исходник на нативном ассемблере имеет расширение '.S', но конечно если хочется, то можно использовать любое другое, например - '.asm' :acute:


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

    .headerflags @"EF_CUDA_TEXMODE_UNIFIED EF_CUDA_SM90 EF_CUDA_VIRTUAL_SM(EF_CUDA_SM90) EF_CUDA_64BIT_ADDRESS" - здесь указываются флаги для компиляции, означающие номер архитектуры и режим адресации памяти 32- или 64- бита. В настоящий момент самые новые карточки (sm_90 и соответственно с большим номером архитектуры) поддерживают только 64-битный режим, потому если программа для CPU 32-битная, то она просто не будет работать с этими видеокартами. Кстати текущая версия Toolkit, тоже поддерживает только режим 64 бита, то есть Nvidia вообще отказалась от 32 бит, не удивительно ведь начиная с серии GT 16xx, все карточки имеют объем оперативной памяти 4 Гигабайт или более. Таким образом флаг EF_CUDA_64BIT_ADDRESS всегда должен присутствовать.

    .elftype @"ET_EXEC" - это тип файла. Помимо этого есть еще 2 типа, но нужно использовать именно такой.
     
    MaKsIm, Mikl___ и UbIvItS нравится это.
  3. Leopard

    Leopard New Member

    Публикаций:
    0
    Регистрация:
    17 июн 2021
    Сообщения:
    14
    А теперь немного о памяти. В отличие от обычных CPU, где есть только 3 вида памяти, не считая регистров - это глобальная, локальная (которая берется из стека) и кэш память нескольких уровней (она есть, но недоступна при написании программ на обычном ассемблере) в GPU помимо "глобальной" (global) и "локальной" (local), еще имеется "постоянная" (constant), "разделяемая" (shared) и "текстурная" (texture) памяти. С "глобальной" и "локальной" GPU памятью все более менее ясно, потому что это тоже самое что и в CPU, а вот "разделяемая" память это очень интересная вещь. Фактически она представляет из себя управляемый кэш первого уровня (аналогична уровню L1, если сравнивать с уровнями кэшей CPU)!!! То есть можно прямо в коде ядра указывать какие байты из регистров процессора и по какому смещению писать в кэш и читать из него. Еще один тип памяти - "постоянная". Из нее можно только читать. Здесь имеются в виду права доступа из функции ядра, так как тут описывается только ассемблер. Справедливости ради надо сказать, что в нее конечно можно и записывать со стороны хоста (host), то есть кода основной программы, который выполняется на CPU. Но это как говорится уже совсем другая история :pardon:. В "текстурную" память так же как и в "постоянную" данные можно только записывать, но естественно она отличается от "постоянной", поэтому и имеет другое название. По сути эти 2 типа памяти совпадают правами доступа, а все остальное у них различное (назначение, управление ее при программировании и так далее).

    Пару слов о дальнейших обозначениях. В тексте ассемблерного кода можно использовать комментарии которые такие же как например и в языке C:
    // строка с комментарием
    /* многострочный комментарий */

    1 - число в десятичной системе
    0x12 - число в шестнадцатеричной системе

    Имена переменных могут содержать заглавные (A - Z) и строчные (a - z) латинские буквы, числа (0 - 9) и знак подчеркивания (_).


    выравнивания (align) могут иметь значения 1, 2, 4, 8 или 16.


    Пример для не инициализированной глобальной памяти (общая для всего cubin файла):

    .section .nv.global,"aw",@nobits /* заголовок секции глобальных данных
    .nv.global - тип секции
    aw - флаги доступа, означающие соответственно чтение ( a ) и запись ( w ) */

    .align 16 // выравнивание в памяти в байтах

    .type first_variable_name,@object // first_variable_name - имя переменной задаваемая разработчиком

    .size first_variable_name,(Label2-Label1) /* размер это переменной в байтах, тут интересно, что разница в скобках обозначает размер в байтах между двумя метками Label1 и Label2 (см. чуть ниже), равное 16 байт с учетом указанного выравнивания */

    Label1:
    .zero 16 /* указывается, что при запуске всей программы на CPU, то есть только при первом запуске ядра, но не при каждом вызове функции ядра (например если ядро запускается в цикле или более одного раза) эта переменная заполняется нулями, но конечно эту строчку можно и не использовать */
    Label2:

    .align 8 // выравнивание в памяти в байтах
    .type second_variable_name,@object // имя переменной задаваемая разработчиком
    .size variable_name,8 // размер это переменной в байтах

    И так далее...

    Пример для инициализированной глобальной памяти (общая для всего cubin файла):

    .section .nv.global.init,"aw",@progbits /* заголовок секции глобальных данных
    .nv.global.init - тип секции
    aw - флаги доступа, означающие соответственно чтение (a) и запись (w) */

    .align 4 // выравнивание в памяти в байтах
    .type table_name,@object // table_name - имя переменной задаваемая разработчиком
    .size table_name,4 // размер это переменной в байтах
    .byte 0,0x1,2,3 // значения 4 байтов переменной table_name - 1, 2, 3 и 4 соответственно

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

    Пример для разделяемой памяти (своя отдельная для каждого ядра):

    .section .nv.shared.Kernel_Name,"aw",@nobits /* заголовок секции глобальных данных
    .nv.shared. - тип секции

    Kernel_Name - имя функции ядра в которой будет использоваться разделяемая память. Это делается потому, что в cubin файле может быть больше одной функции ядра и разные ядра могут различные размера кэша или же конкретное ядро вообще может не использовать разделяемую память

    aw - флаги доступа, означающие соответственно чтение (a) и запись (w)*/

    .align 1 // выравнивание в памяти в байтах
    .zero 512 /* указываем сколько байт кэша мы будем использовать, а не то, что будет заполнение нулями !!! Так как данные кэша определены только на время запуска блока потоков, то инициализировать байты кэша не имеет смысла */

    Для разделяемой памяти можно использовать только одну секцию на ядро !!!
     
    DreadPirateRoberts, MaKsIm и Mikl___ нравится это.
  4. Leopard

    Leopard New Member

    Публикаций:
    0
    Регистрация:
    17 июн 2021
    Сообщения:
    14
    Пару слов о существующих типах переменных :
    .byte - размер 1 байт
    .short - размер 2 байта
    .word - размер 4 байта
    .dword - размер 8 байт

    При этом данные могут быть и не одиночными переменными, а массивами.
    Например .byte 1,2,3,4 - это массив из 4 элементов каждый размером 1 байт, элементы массива разделяются запятой

    Массив даже может состоять из элементов различного размера как в следующем примере:
    .byte 1,2,3,4
    .word 5,6,7,8

    Каждая строка начитается с типа элементов этой строки. В конце запятая не ставится. Но при этом не надо забывать про выравнивание, где в данном случае оно 4, а не 1 (.align 4). Определяется по большему размеру элемента. Чтение или запись данных в любой тип памяти по не выровненному адресу всегда приводит к аварийном завершению (исключению) при выполнении кода функции ядра поэтому никогда не стоит забывать про этот факт (ОЧЕНЬ СЕРЬЕЗНАЯ ШТУКА)!!!!! Когда пишется код на ассемблере для обычного CPU с использованием регистров общего назначения, то об этом не задумываешься. Кстати, для векторных регистров CPU (SSE, SSE2) есть нечто подобное, но там уже самому можно выбивать между MOVDQA (перемещение выровненных данных) и MOVDQU (перемещение не выровненных данных), а для GPU такого нет.

    Пример для "постоянной" памяти (своя для каждого ядра) :

    .section .nv.constant0.Kernel_Name,"a",@progbits /* заголовок секции постоянных данных

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

    Kernel_Name - имя функции ядра.

    a - флаг доступа, означающий чтение */


    .align 8 // выравнивание в памяти в байтах. Если в функцию ядра будут передаваться только 4 - байтные параметры, то тогда здесь достаточно указать всего лишь значение 4. Если размер хотя бы одного из параметров 8 байт , то обязательно нужно установить значение 8.

    .zero 328 /* размер памяти в байтах , ее размер вычисляется так (приведу как пример только для 32-битной модели памяти, для 64-битной будет по аналогии) - всегда = 320 байта + еще общий размер всех параметров в байтах, передаваемых в функцию ядра. К примеру если передается только один параметр размером 4 байта, то получится 324 (320 + 4). Если 2 параметра и каждый по четыре байта (320 + 4 + 4) или один параметр длиной 8 байт (320 + 8), то уже 328. Начальные 320 байта содержат служебную информацию для ядра и ее можно прочитать, но только с этого уровня нативного ассемблера. Ни в псевдоассемблере PTX , ни тем более в языке высокого уровня С этого сделать нельзя!!! При 64-битной модели памяти почти все тоже самое за исключением того, что некоторые данные служебной информации становятся уже 64-битные, поэтому так сказать базовое число будет уже не 320, а больше */

    Первый банк постоянной памяти (.nv.constant1) не используется и зарезервирован. Данная секция всегда отсутствует.

    .section .nv.constant2.Kernel_Name,"a",@progbits /* заголовок секции постоянных данных

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

    Kernel_Name - имя функции ядра.

    a - флаг доступа, означающий чтение */

    .align 4 // выравнивание в памяти в байтах.
    .word 0x12345678 , 0x12345678 // непосредственно данные банка памяти

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


    .section .nv.constant3,"a",@progbits /* заголовок секции постоянных данных

    .nv.constant3. - тип секции. Третий банк постоянной памяти.
    Общий для всех ядер в cubin файле. Как раз в этот банк памяти и копируются данные из программы выполняемой на CPU.

    a - флаг доступа, означающий чтение */

    .align 4 // выравнивание в памяти в байтах
    .type Var_Name,@object // Var_Name - имя переменной
    .size Var_Name,4 // размер переменной Var_Name
    .short 1,2 /* Значение переменной. Также вместо это строчки может быть к примеру ".zero 4", которая означает, что переменная не инициализирована, а точнее неявно инициализирована нулями , но конечно сразу одновременно двух строк быть не должно либо ".zero 4", либо ''.short 1,2 " */
     
    Mikl___ и MaKsIm нравится это.
  5. Leopard

    Leopard New Member

    Публикаций:
    0
    Регистрация:
    17 июн 2021
    Сообщения:
    14
    Пример "информационной" секции ( одна общая для всего cubin файла ) :

    .section .nv.info,"",@"SHT_CUDA_INFO" // права доступа здесь не указываются
    SHT_CUDA_INFO // флаги для секции
    .nv.info // тип секции

    Эта секция имеет несколько записей, каждая из которых начинается со следующей структуры :
    .align 4 - выравнивание на 4 байта
    2 байта - тип записи
    2 байта - размер записи

    Далее идут данные текущей записи размером "размер записи" байт.


    Полный пример одной из записей :

    // Часть одинаковая для всех записей
    .align 4 // выравнивание на 4 байта
    .short 0x1204 /* тип записи. В данном случае это минимальный размер стека, то есть размер локальной памяти выделяемой в стеке на каждый поток */
    .short 8 // размер записи
    // Часть одинаковая для всех записей

    .align 4 // выравнивание на 4 байта
    .word index@(kernel_name) // kernel_name - имя функции ядра
    .word 0x00000010 // размер стека в байтах на каждый поток



    .align 4 // выравнивание на 4 байта
    .short 0x1104 // тип записи. В данном случае это размер кадра
    .short 8 // размер записи

    .align 4 // выравнивание на 4 байта
    .word index@(kernel_name) // kernel_name - имя функции ядра
    .word 0x00000010 // размер кадра в байтах на каждый поток



    .align 4 // выравнивание на 4 байта
    .short 0x2304 // тип записи. В данном случае это максимальный размер стека
    .short 8 // размер записи

    .align 4 // выравнивание на 4 байта
    .word index@(kernel_name) // kernel_name - имя функции ядра
    .word 0x00000000 // максимальный размер стека в байтах на каждый поток
     
    MaKsIm нравится это.
  6. Leopard

    Leopard New Member

    Публикаций:
    0
    Регистрация:
    17 июн 2021
    Сообщения:
    14
    Также в секции .nv.info бывает еще такая запись :

    .align 4 // выравнивание на 4 байта
    .short 0x2f04 // тип записи. В данном случае это число регистров в функции ядра
    .short 8 // размер записи

    .align 4 // выравнивание на 4 байта
    .word index@(kernel_name) // kernel_name - имя функции ядра
    .word 64 // число регистров в функции ядра на каждый поток

    Вообще в секции "инфо" могут быть разные типы записей и их разное количество в зависимости от того какие команды GPU используются в функции ядра. А даже если содержимое функции совпадает, то число и тип записей может отличаться при разных версиях ToolKit'а.


    В коде ядра размер локальной памяти в стеке берется из секции .nv.info, а смещение на начало стека выполняется командой MOV. Как пример полная команда выглядит следующим образом: MOV R1, c[0x0][0x20];
    Здесь происходит загрузка значения по смещения 32 из банка 0 (ноль) постоянной памяти в обычный регистр общего назначения. В конце каждой команды ставится точка с запятой. Регистры нумеруются с нуля (R0, R1 и так далее... ). Если например в секции "инфо" указано, что используется 32 регистра, то это означает задействование в функции ядра регистров с номерами от R0 до R31 включительно.

    Пример именованной секции "инфо" (для каждого ядра отдельная) :

    .section .nv.info.kernel_name,"",@"SHT_CUDA_INFO" /* права доступа здесь не указываются
    .nv.info - тип секции
    kernel_name - имя функции ядра
    SHT_CUDA_INFO - флаги для секции */

    Полный пример одной из записей:
    // Часть одинаковая для всех записей
    .align 4 // выравнивание на 4 байта
    .short 0x3704 // тип записи. В данном случае это версия CUDA API
    // Часть одинаковая для всех записей

    .short 4 // размер записи
    .word 0x00000075 /* это число деленное на 10 дает старшую версия, а остаток от деления на 10 младшую версия CUDA API. В данном примере получается, что версия равна "11.7" */

    .align 4 // выравнивание на 4 байта
    .short 0x0a04 /* тип записи. В данном случае это размер служебной информации в байтах и суммарный размер всех параметров ядра в байтах */

    .short 8 // размер записи
    .align 4 // выравнивание на 4 байта
    .word index@(kernel_name) // kernel_name - имя функции ядра
    .short 0x0140 // размер служебной информации в байтах
    .short 12 /* суммарный размер всех параметров ядра в байтах. Например если всего 3 параметра и каждый размером 4 байта */

    .align 4 // выравнивание на 4 байта
    .short 0x1903 // тип записи. В данном случае это суммарный размер всех параметров ядра в байтах
    .short 16 // Например если всего 3 параметра и каждый размером 8 байт

    .align 4 // выравнивание на 4 байта
    .short 0x1404 // тип записи. В данном случае это информация о параметре ядра
    .short 12 // размер записи
    .word 0x00000000 // Индекс символьной таблицы
    .short 0x0000 // номер параметра ( первый параметр имеет номер ноль
    .short 0x0000 // смещение начала параметра в байтах, считая от нулевого смещения
    .byte 0x00, 0xf0, 0x11, 0x00 /* Последние 14 бит указывают на размер в байтах данного параметра. В данном примере это равно 4 байта */

    .align 4 // выравнивание на 4 байта
    .short 0x1b03 /* тип записи. В данном случае это максимальное число регистров для данной архитектуры видеокарты */
    .short 0x00ff /* в данном примере равно 255. Такое значение присутствует с архитектуры sm32 до самой новой sm90 */

    .align 4 // выравнивание на 4 байта
    .short 0x1c04 /* тип записи. В данном случае это список смещений на все инструкции выхода из функции ядра (EXIT) в коде, отсчитывается от первой команды, которая имеет смещение ноль */
    .short 8 // Общий размер таблицы смещений, размер каждого смещения 4 байта
    .word 0x000060c0 // Первое смещение
    .word 0x000060d0 // Второе смещение
    // И так далее...

    .align 4 // выравнивание на 4 байта
    .short 0x1d04 /* тип записи. В данном случае это список смещений на все инструкции загрузки номера текущего блока из специального регистра в обычный (S2R) в коде, отсчитывается от первой команды, которая имеет смещение ноль. Например полностью команда может выглядеть как S2R R1, SR_CTAID.X ;, где R1 - обычный регистр с номером один */
    .short 8 // Общий размер таблицы смещений, размер каждого смещения 4 байта
    .word 0x000060c0 // Первое смещение
    .word 0x000060d0 // Второе смещение
    // И так далее...
     
    MaKsIm нравится это.
  7. Leopard

    Leopard New Member

    Публикаций:
    0
    Регистрация:
    17 июн 2021
    Сообщения:
    14
    Пример заголовка секции "кода" (для каждого ядра естественно создается отдельная секция) :

    .section .text.kernel_name,"ax",@progbits /* .text. - тип секции кода для исполнения на GPU. kernel_name - имя функции ядра. ax - флаги доступа, означающий соответственно чтение (a) и исполнение (x) */

    .sectioninfo @"SHI_REGISTERS=64" /* информация о секции
    SHI_REGISTERS - число регистров используемых данной функцией ядра. В данном примере равно 64, то есть как я уже объяснял ранее в предыдущем сообщении от R0 до R63 включительно */

    .align 32 /* выравнивание секции в памяти в байтах. В данном случае равно 32 (для архитектур sm50 - sm62), но может быть и другое значение. К примеру 64 (для архитектур sm30 - sm37) или 128 (для архитектур sm70 - sm90), это зависит от архитектуры видеокарты. Кстати, смещение в нулевом банке постоянной памяти на начало стека для локальной памяти тоже зависит от архитектуры и может иметь значение 32 (для архитектур sm50 - sm62), 40 (для архитектур sm70 - sm90) или 68 (для архитектур sm30 - sm37). */

    .global kernel_name // kernel_name - имя функции ядра
    .type kernel_name,@function // тип объекта - функция
    .size kernel_name,(begin_ - end_) // размер секции кода в байтах
    .other kernel_name,@"STO_CUDA_ENTRY STV_DEFAULT" // флаги для кода ядра

    begin_:
    ............... //код ядра между 2 метками начала и конца команд
    end_:

    Интересный факт, что GPU от NVidia это 32-разрядный процессор, хотя адресация памяти может быть как 32-битной так и 64-битной. Поэтому если бы они сделали процессор 64-разрядным, то скорость некоторых алгоритмов на GPU увеличилась бы сразу в 2 раза без всяких дополнительных ухищрений.

    Каждая команда GPU в отличие от CPU имеет размер 8 байт (для архитектур sm30 - sm62) и 16 байт (для архитектур sm70 - sm90). Это очень удобно. Даже если не известна кодировка какой-то конкретной команды, то всегда известны смещения по которым начинаются предыдущая и следующая команды процессора.

    Есть еще один интересный факт. Для архитектур sm30 - sm37 на каждые 7 команд добавляется еще одна инструкция для "логического контроля", которая не выполняет вычислений из кода функции ядра, но дополнительно занимает 8 байт. Сначала идет эта инструкция, а потом сразу за ней подряд 7 обычных команд и это циклически повторяется на протяжении всей секции кода. Таким образом общий размер всего кода увеличивается на 14 процентов.

    Для архитектур sm50 - sm62 на каждые 3 команды добавляется еще одна инструкция для "логического контроля". Сначала идет эта инструкция, а потом сразу за ней подряд 3 обычных команды и это циклически повторяется на протяжении всей секции кода. Таким образом общий размер всего кода увеличивается на 33 процента.
     
    MaKsIm нравится это.
  8. Marylin

    Marylin Active Member

    Публикаций:
    0
    Регистрация:
    17 фев 2023
    Сообщения:
    107
    Leopard, вы в курсе, что в редакторе сообщений есть теги [соde=язык] и [/соdе]?
    А то в ваших постах не разберёшь, где текст, а где исходник. Ведь в отформатированном виде намного лучше инфа воспринимается:

    Код (C++):
    1. .global kernel_name // kernel_name - имя функции ядра
    2. .type kernel_name,@function // тип объекта - функция
    3. .size kernel_name,(begin_ - end_) // размер секции кода в байтах
    4. .other kernel_name,@"STO_CUDA_ENTRY STV_DEFAULT" // флаги для кода ядра
    5.  
    6. begin_ :
    7. ............... //код ядра между 2 метками начала и конца команд
    8. end_ :
     
  9. Leopard

    Leopard New Member

    Публикаций:
    0
    Регистрация:
    17 июн 2021
    Сообщения:
    14
    Знаю , но это же не исходный код на С и даже не обычный ассемблер , тут ведь совсем другой синтаксис .
     
  10. Marylin

    Marylin Active Member

    Публикаций:
    0
    Регистрация:
    17 фев 2023
    Сообщения:
    107
    ..да без разницы какой синтаксис, лишь-бы выделить код.
     
  11. Thetrik

    Thetrik UA6527P

    Публикаций:
    0
    Регистрация:
    25 июл 2011
    Сообщения:
    860
    Тут есть нейтральный тег CODE