Главная
Вычислительные ресурсы
C чего начать
Вопросы - ответы
Документация
Исследования
Контакты

Часть 3.

Описание языка Автокод Stream.

  1. Взаимодействие схемы и управляющей программы.
    1.1 Модель взаимодействия схемы и управляющей программы.
    1.2 Описание интерфейсных объектов.
    1.3 Пример 1.
    1.4 Пример 2.
  2. Построение арифметических конвейеров.
    2.1 Описание арифметических объектов.
    2.2 Использование арифметических устройств в схеме.
    2.3 Пример 3.
    2.4 Пример 4.
  3. Принципы построения эффективных схем.
    3.1 За счет чего схемная реализация работает быстрее программной.
    3.2 Как строить эффективные схемы.
    3.3 Борьба за экономию ресурсов.

Основные понятия.

Из Части 1 настоящего Руководства мы узнали о том, как в принципе строятся схемы вычислительных сопроцессоров. Часть 2 была посвящена описанию языка Автокод HDL. Мы убедились, что его использование, даже для достаточно примитивных вычислений, превращается в очень громоздкий и, зачастую, малопонятный процесс. Наиболее трудоемкие аспекты создания схемы решено было автоматизировать. Язык, в который были включены средства такой автоматизации, получил название Автокод Stream.

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

Автокод Stream предназначен для написания основного компонента схемы. Транслятор, встретив определенные языковые конструкции (о них будет рассказано ниже), сам будет создавать собственные компоненты.

Трансляция схемы запускается командой avts и проходит 3 этапа:

  1. Трансляция исходного текста с языка Автокод Stream на базовый Автокод. Результат записывается во временный файл с расширением .avt.trans. Собственные компоненты транслятор создает на языке VHDL и записывает в файл avtocomponent.vhd. Если схема написана на базовом Автокоде, то файл avtocomponent.vhd не создается, а временный файл будет практически совпадать с исходным.
  2. Трансляция текста временного файла с базового Автокода на язык VHDL. Если схема – основной компонент, то результат записывается в файл vector_proc_N.vhd, где N — платформенная разрядность слова (32, 64 или 128). В конце трансляции в конец этого файла переписывается файл avtocomponent.vhd. При отсутствии в команде avts ключа для синтеза, на этом этапе трансляция завершается.
  3. Синтез схемы – трансляция текста из файла vector_proc_N.vhd в битовую последовательность. Результат записывается в файл download.bit.

Более подробную информацию об аргументах команды запуска трансляции avts можно прочитать в Руководстве пользователя.

Перечислим кратко новые конструкции, введенные в язык.

Изменился вид стандартного заголовка главного компонента. Теперь он должен иметь вид:

mainprogram<N>
  <объявления>
endprogram
где <N> - платформенная разрядность слова (32, 64 или 128).

Работа схемы с таким заголовком обязана заканчиваться исполняемым оператором

return [возвращаемое значение]

Этот оператор должен присутствовать в разделе состояний.

Его выполнение сигнализирует управляющей программе о завершении работы схемы, и передает ей (программе) возвращаемое значение.

Возвращаемое значение, если оно указано, должно быть либо скаляром платформенной разрядности, либо целочисленным арифметическим выражением, возвращающим скалярное значение платформенной разрядности. Если возвращаемое значение не указано, передается ноль.

В язык вводится новый тип переменной – index. Это скалярный регистр разрядностью не более 24 бит, служащий счетчиком адресов чтения массивов векторной памяти ram.

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

<тип_объекта>::<описание>

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

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

Последняя глава настоящей части Руководства посвящена общим принципам построения эффективных схем и способам ускорения вычислений.

1. Взаимодействие схемы и управляющей программы.

1.1. Модель взаимодействия схемы и управляющей программы.

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

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

Схема принимает от управляющей программы входные параметры, и выдает выходные параметры. Как входные, так и выходные параметры могут быть параметрами-скалярами или параметрами-массивами.

Управляющая программа выполняет, вместе с управляемой схемой, некоторый рабочий цикл, причем, как правило, неоднократно.

Рабочий цикл программы состоит из следующих действий:

  • передача в схему входных параметров,
  • запуск рабочего цикла схемы,
  • ожидание сигнала конца рабочего цикла от схемы,
  • прием из схемы выходных параметров.

Схема, в свою очередь, в каждый момент времени находится в одном из двух режимов: в режиме ожидания запуска программой ее (схемы) рабочего цикла, или в рабочем цикле как таковом. После отработки рабочего цикла и выдачи в программу сигнала о его окончании, схема должна возвращаться в режим ожидания запуска. Сразу после начального сброса схема также обязана войти в режим ожидания запуска.

В языке Автокод Stream реализовано два варианта взаимодействия программы и схемы в рамках данной модели: работа программы и схемы без пересечения во времени активных действий (передача данных программой и рабочий цикл схемы) и с пересечением, называемым режимом подкачки.

Вариант 1. Работа без пересечения.

Во время передачи параметров (запись/чтение) со стороны управляющей программы, схема обязана находиться в режиме ожидания запуска. На время работы схемы, управляющая программа обязана находиться в режиме ожидания результатов вычислений.

Последовательность действий в этом режиме выглядит так:

в программе в схеме
запись в схему входных параметров args1; ожидание запуска;
запуск рабочего цикла схемы и ожидание его окончания; рабочий цикл вычислений res1 и последующая выдача сообщения об окончании работы;
. . .
чтение результатов res1; ожидание запуска;

Вариант 2. Работа в режиме подкачки.

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

в программе в схеме
запись в схему входных параметров args1 ожидание запуска;
запуск работы схемы и запись входных параметров args2 рабочий цикл вычислений res1, выдача сообщения об окончании работы и ожидание запуска;
ожидание окончания работы схемы, следующий запуск работы схемы, чтение результатов res1, запись входных параметров args3; рабочий цикл вычислений res2, выдача сообщения об окончании работы и ожидание запуска;
ожидание окончания работы cхемы, запуск работы схемы, чтение результатов res2, запись входных параметров args4; рабочий цикл вычислений res3 и выдача сообщения об окончании работы и ожидание запуска;
. . .
чтение последних результатов resN; ожидание запуска;

Со стороны управляющей программы различия в режимах затронут организацию цикла обращений к схеме (в случае режима с подкачкой цикл обращений к схеме похож на цикл обмена данными в MPI программах). В схеме – коснутся только задания соответствующего типа массива ram в разделе деклараций. Более подробно об этом будет рассказано чуть ниже.

1.2. Описание интерфейсных объектов.

Интерфейсные объекты предназначены для организации приема и размещения входных параметров в схеме, а также выдачи результатов вычислений по запросу управляющей программы.

На передачу входных/выходных параметров накладываются следующие ограничения:

Количество входных и выходных параметров-массивов не ограничено. Все массивы имеют единую нумерацию (адресацию), начиная с 0. Разрядность элемента массива-параметра – платформенная (32, 64 или 128 разрядов).

Количество входных скалярных параметров – максимум 3 с адресами 0, 1 и 2. Разрядность – 32 разряда. Прием 0-го параметра воспринимается схемой как запуск рабочего цикла схемы.

Есть возможность дополнительно передать в схему блок скалярных параметров. Количество параметров в блоке – не ограничено. Разрядность каждого параметра в блоке – платформенная.

Из схемы можно выдать только один выходной скалярный параметр в операторе return. Разрядность – платформенная.

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

Со стороны управляющей программы реализация данной модели достигается использованием набора специальных функций доступа:

logic_put(int ram, int addr, void *data, int len)
  • запись len слов массива data в память схемы, в массив номер ram, со смещением addr;
logic_get(int ram, int addr, void *data, int len)
  • чтение из памяти схемы, из массива номер ram, со смещением addr, len слов в массив data;
logic_last_get(int ram, int addr, void *data, int len)
  • последнее чтение из памяти схемы, из массива номер ram, со смещением addr, len слов в массив data
    (данная функция используется только при работе в режиме подкачки);
logic_put_block_reg( void *par, int count )
  • запись count слов массива par в блок скалярных параметров схемы;
logic_init(int val)
  • запуск рабочего цикла схемы с одновременной записью 0-го параметра. Если 0-й параметр не нужен, то передавать можно любое число;
logic_register(int addr, int val)
  • запись в скалярный параметр схемы номер addr значения val
    (addr=1 – 1-й параметр, addr=2 – 2-й параметр).
    Если будет указан номер параметра, отличный от 1 или 2, то ничего не делается;
logic_result(WORD *ans)
  • чтение в ans выходного параметра из схемы. Разрядность – платформенная;
logic_wait(int tim, int *val)
  • ожидание окончания рабочего цикла схемы.

Кроме специальных функций доступа к параметрам схемы, предусмотрены еще три дополнительных функции:

init_coprocessor

– инициализация набора ускорителей;

set_coprocessor

– выбор текущего ускорителя;

cputime

– функция измерения времени.

Специальные функции доступа к параметрам схемы используют внутри себя обращения к базовым функциям стандартной интерфейсной библиотеки, описанным в той части настоящего Руководства, которая посвящена Автокоду HDL. Управляющая программа, использующая упомянутые здесь специальные функции доступа, не должна напрямую вызывать функции стандартной библиотеки. К трем перечисленным только что дополнительным функциям это не относится — программа может и должна вызывать их напрямую.

Со стороны схемы прием и выдача параметров (кроме выходного скалярного параметра) реализованы как интерфейсные объекты, объявление которых должно быть записано в разделе заголовка внутри именных скобок mainprogram<N> и endprogram:

mainprogram<N>
Memory::(<список_переменных>)
Register::(<список_переменных>)
BlockRegister::(<список_переменных>)
endprogram

где N - платформенная разрядность (32, 64 или 128).

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

Интерфейсный объект "Memory".

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

Memory::(in a, out b, inout c)

Данное объявление говорит о том, что параметрами - массивами являются 3 массива векторной памяти – a, b и c. При этом, массив векторной памяти 'a' – входной параметр (извне только запись), и его номер равен 0, массив векторной памяти 'b' – выходной параметр (извне только чтение), и его номер равен 1, массив векторной памяти 'c' – входной/выходной параметр (извне возможны и запись, и чтение), и его номер равен 2. Порядок перечисления блоков памяти должен соответствовать нумерации массивов в вызывающей программе (0, 1 и 2).

Переменные a, b и c должны быть объявлены в разделе деклараций как массивы векторной памяти ram типа rams и ramd. Встретив такое объявление, транслятор сам включит в схему все необходимые соединения и действия для записи и/или чтения этих массивов со стороны управляющей программы.

В описании языка Автокод HDL был представлен только базовый тип памяти ramb с двумя независимыми портами ‘a’ и ‘b’, которыми программист распоряжался по своему усмотрению. Для типов rams и ramd добавлен еще порт ‘c’ со своими регистрами доступа, которые будут использованы транслятором для соединения с внешним интерфейсом схемы. Для программиста доступны только порты ‘a’ и ‘b’ с теми же регистрами доступа, что и в базовом типе.

Тип rams используется в схеме, когда обмен данными с управляющей программой и рабочий цикл схемы разделены. Когда идет обмен данными, то внутрисхемное обращение к памяти закрыто. Тип ramd используется при работе в режиме подкачки, т.е. обеспечено одновременное обращение к памяти и со стороны управляющей программы и схемы. Существуют еще две разновидности этих типов – rams2 и ramd2, для случаев передачи данных типа int или float в схему 64-разрядной платформы. Более подробная информация изложена в части Руководства «Библиотечные компоненты и функции».

Если при объявлении будет все же указан тип памяти ramb, то транслятор ограничится только резервированием номера данной памяти, а все остальное придется писать самостоятельно.

Интерфейсный объект "Register".

Обеспечивает прием в схему трех входных скалярных параметров. Его объявление имеет вид:

Register::(<список_переменных>)

Транслятор языка Автокод Stream всегда создает в схеме векторный регистр argv(3) разрядностью 32 бита, доступный внутри схемы только для чтения. Изменение значений вектора argv происходит только со стороны управляющей программы после вызова соответствующих функций:
argv[0] = val при вызове функции logic_init(val).
argv[1] = val при вызове функции logic_register (1, val).
argv[2] = val при вызове функции logic_register (2, val).

Список переменных может состоять максимум из трех элементов. Каждый элемент – это либо скалярный регистр (разрядностью от 0 до 32), либо скаляр с операцией сдвига, либо набор скаляров, представленный в виде операции кортеж. В момент запуска рабочего цикла схемы значения с вектора argv переписываются в указанные переменные, которые будут доступны в схеме и на чтение, и на запись.

Например запись:

Register::(a1, a2>>3, {bl,br})
. . . . . 
declare
reg 32 a1
reg 24 a2
reg 20 bl
reg 0 br
. . . . .
означает следующее:
  • argv[0] сохранить в a1;
  • младшие 24 разряда argv[1], сдвинутые вправо на 3 разряда – в a2;
  • argv[2] распределить так: 0-й разряд сохранить в бите br, а следующие 20 разрядов – в регистре bl.
Понятно, что суммарная разрядность регистров в кортеже не должна превышать 32 разряда.

Если список пустой, или объявление объекта Register вообще отсутствует, то в схеме доступен только вектор argv, который можно только читать.

Интерфейсный объект "BlockRegister".

Обеспечивает прием в схему одного блока входных скалярных параметров. Встретив объявление вида:

BlockRegister::(<список_переменных>)
транслятор языка Автокод Stream создает в схеме векторный регистр argvblock(N) платформенной разрядности, доступный внутри схемы только для чтения. N – число скалярных элементов в блоке. Изменение значений вектора argvblock происходит только со стороны управляющей программы после вызова функции
logic_put_block_reg(void *par, int N)

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

Возможен вариант объявления объекта в виде:

BlockRegister::(N)
где N – размер блока. В этом случае в схеме доступен только вектор argvblock.

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

Например:

BlockRegister::(c1, c2, v3)
. . . . . 
declare
reg 32 c1, c2, v3(2)
. . . . .

Совокупная длина вектора N = 4, значит, будет создан вектор argvblock(4), в который будут записаны поступившие из программы параметры, а при запуске рабочего цикла схемы они будут перезаписаны в указанные регистры:

c1    = argvblock[0]
c2    = argvblock[1]
v3[0] = argvblock[2]
v3[1] = argvblock[3]

Если создаваемая схема будет работать в режиме подкачки, и в ней будут участвовать параметры-скаляры, меняющие свое значение, то интерфейсные объекты BlockRegister и/или Register обязательны в полном объеме (т.е. с указанием списка переменных). Это продиктовано тем, что изменение значений данных параметров на векторах argvblock и argv будет происходить внутри рабочего цикла схемы.

Прежде, чем рассмотреть применение интерфейсных объектов в реальных схемах, необходимо отметить следующее. Транслятор, встретив объявления объектов типа Memory, Register и BlockRegister, будет вынужден создавать в схеме определенное число служебных переменных. Во избежание пересечения имен, все имена служебных переменных (кроме argv и argvblock) задаются, начиная с префикса avt_ или cpu_. При создании собственных переменных не следует использовать имена с такими префиксами.

1.3. Схема, складывающая два целых числа (пример 1).

Это пример 1 из Части 1 руководства. Запись этого примера на Автокоде Stream демонстрирует реализацию передачи параметров-скаляров.

Начало схемы

mainprogram32
Register::(a,b)
endprogram

declare
    reg 32 a, b
enddeclare

{
   return (a + b)
}

Конец схемы

Объявление в заголовке Register::(a,b) описывает регистры а и b как входные параметры типа «регистр». Их описание говорит транслятору о том, что он обязан обеспечить передачу в эти внутрисхемные регистры значения соответствующих параметров в момент запуска схемы. Вопрос о том, когда управляющая программа пожелает передать эти параметры в схему и как именно будут использованы при этом REG_IN_A, REG_WE_B и прочие низкоуровневые сущности стандартного заголовка из базового языка, нас отныне не волнует. Соответствующую часть схемы транслятор изготовит сам, и вставит в нужные разделы автоматически, без нашего участия.

В отличие от примера 1 на базовом языке, в нашей теперешней схеме суммирование выполняется не в разделе действий на каждом такте, а в разделе последовательных действий. Оно (суммирование) теперь образует рабочий цикл схемы, и ему предшествует (незримо) ожидание запуска схемы программой. Схема не будет выполнять суммирование, пока управляющая программа не запустит рабочий цикл. Поскольку в разделе комбинационной логики и в разделе Background для этого примера писать нечего, то после объявления переменных идет раздел последовательных действий. Представлен он одним состоянием, в котором записан один оператор return. Он выдает управляющей программе сигнал о завершении рабочего цикла, после чего возвращает схему в состояние ожидания запуска – в то самое, которое транслятор любезно написал за нас целиком и полностью. Если оператор return не пустой, то, наряду с сигналом о завершении работы, на выходной служебный регистр записывается его параметр. Параметром может быть либо скалярная переменная, либо результат выполнения операции. В нашем случае запишется результат сложения (a+b). Теперь программа может извлечь из схемы выходной параметр типа «регистр» (он – единственный и имеет платформенный размер).

Чтобы транслятор, формируя для нас интерфейсную логику схемы, не нарушил, ненароком, Правило единственности источника значения, не следует присваивать входным параметрам значений где-либо, кроме раздела последовательных действий. Оператор return также можно писать только в разделе последовательных действий.

Теперь рассмотрим управляющую программу.

Управляющая программа для схемы, написанной на Автокоде Stream, имеет право пользоваться для передачи данных между программой и схемой только специальными функциями доступа. Имена этих функций начинаются с «logic_». Смешение в одной управляющей программе функций передачи данных из Части 1 (to_register(), from_coprocessor() и т. п.) и функций «logic_» приведет, в общем случае, к непредсказуемым последствиям. В точной аналогии со смешением в одной программе обращений к функциям read/write с обращениями к функциям fread/fwrite в отношении одного и того же файла. Как и в последнем случае, такое смешение на практике все же иногда (очень редко) допускается, но программист должен тогда очень точно понимать, что именно он делает.

Наша управляющая программа использует для передачи данных исключительно функции «logic_»:

#include <stdio.h>
#include <avtokod/comm.h>
int main( void )
{
	int ans;
	WORD result;

	init_coprocessor( 0, 0 ); 
	logic_register(1,2);
	logic_init(3);
	logic_wait(0,&ans);
	logic_result(&result);
	printf( "result: %d\n", result );
	logic_init(5);
	logic_wait(0,&ans);
	logic_result(&result);
	printf( "result: %d\n", result );

	return 0;
}

Функция logic_register(nreg, val) передает значение val в схему для присваивания скалярному входному параметру типа «регистр», под номером nreg. Скалярные входные параметры типа «регистр» занумерованы от нуля, по порядку их перечисления в объявлении «Register». В нашем примере параметр номер 1 – это внутрисхемная переменная b.

Функция logic_init(val) передает значение val в схему для присваивания нулевому скалярному входному параметру типа «регистр», после чего запускает рабочий цикл схемы. В нашем примере нулевой параметр – это внутрисхемная переменная a.

Как только 0-й параметр примет переданное значение, раздел последовательных действий схемы начнет выполняться с первого из состояний, явно написанных программистом, в нашем примере – со сложения a и b.

ВАЖНОЕ ЗАМЕЧАНИЕ. 0-й параметр передается в схему при каждом запуске рабочего цикла (logic_init), перезаписывая ранее переданное значение. Все остальные параметры-скаляры передаются только при вызове функций logic_register и logic_put_block_reg, между вызовами этих функций в схеме хранятся предыдущие значения этих параметров. Поэтому при нескольких запусках логично через logic_init передавать наиболее часто меняющийся аргумент.

Функция logic_wait(n, &ans) ждет сигнала от схемы о завершении рабочего цикла. Схема формирует этот сигнал, выполняя оператор return в разделе последовательных действий. Аргументы этой функции фактически предназначены только для отладки. Значение n задает максимальное время ожидания сигнала от схемы в секундах, нулевое значение означает «ждать вечно». Значение целочисленной переменной ans будет равно нулю, если время ожидания истекло, а сигнал так и не пришел, во всех остальных случаях оно будет равно единице.

Функция logic_result(&result) извлекает из схемы значение выходного параметра типа «регистр», помещая его в переменную result.

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

Начало схемы

mainprogram32
endprogram
declare
enddeclare
{
     return (argv[0] + argv[1])
}

Конец схемы

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

1.4. Схема, суммирующая целочисленный массив заданной длины (пример 2).

Это пример 2 из Части 1 руководства. Запись этого примера на Автокоде Stream демонстрирует реализацию передачи параметров-массивов. Схема примера такова:

Начало схемы:

mainprogram32
Memory::(inout array)
Register::(L)
endprogram

declare
    reg 24 L
    reg 32 sum
    ram 32 array(rams, 1, 16384)
enddeclare  
{
    sum = 0
    array.addrb = 0
}
{
    array.addrb++
}

loop1:
{
    sum += array.doutb
    if (array.addrb < L)
       array.addrb++
       next loop1
    endif
}
{
    return sum
}

Конец схемы.

Объявление в заголовке Memory::(inout array) описывает массив array как параметр типа «массив», являющийся одновременно входным и выходным. Это означает, что, когда схема находится в режиме ожидания запуска рабочего цикла, управляющая программа может как записывать данные в этот массив, так и читать данные из него. Напомним, что в примере 2 из Части 1 настоящего Руководства мы сознательно оформили массив как пригодный не только для записи со стороны управляющей программы, но и для чтения, хотя решаемая в этом примере задача сама по себе чтения массива не требует.

В объявлении массива памяти

ram 32 array(rams, 1,16384) 
видим новый тип rams. Это специальный тип для массивов-параметров. Как видно из схемы, обращение к регистрам доступа памяти этого типа ничем не отличается от знакомого нам массива общего вида ramb. Дополнительный порт «c», который имеется у типа rams, используется исключительно транслятором для автоматической организации доступа к массиву как к параметру из управляющей программы.

Очищенная от взаимодействия с управляющей программой логика суммирования массива записывается тривиально, и ничего нового по сравнению с Примером 2 из Части 1 не содержит. Вычисленное значение возвращается управляющей программе точно таким же способом, как в предыдущем примере.

Управляющая программа для нашего примера выглядит так:

#include <stdio.h>
#include <avtokod/comm.h>
#define L 128
	int main( void )
{
	int i;
	int ans, result;
	static int array[L];
	init_coprocessor( 0, 0 );
	for ( i = 0; i < L; i++ ) array[i] = i;
	logic_put( 0, 0, array, L );
	logic_init( L );
	logic_wait( 0, &ans );
	logic_result( &result );
	printf( "result: %d\n", result ); // = 8128
	for ( i = 0; i < L; i++ ) array[i] = -1;
	logic_get( 0, 0, array, L );
	for ( i = 0; i < L; i++ ) 
	 {
	  if ( array[i] != i ) printf( "0x%x 0x%x\n", i, array[i] );
	 }
	printf("Over\n" ); 
	return 0; 
}

Цикл в конце программы, за которым следует печать «Over», проверяет работу массива-параметра array в режиме чтения.

2. Построение арифметических конвейеров.

2.1. Описание арифметических типов объектов.

Арифметический конвейер — набор устройств, реализующих потоковые вычисления. В ходе построения конвейера для каждой арифметической операции выделяется свое устройство, на вход которого подаются, в определенном темпе, потоки данных со входа конвейера или потоки результатов вычислений предыдущего устройства. Результаты вычислений устройств нигде не хранятся, а образуют выходные потоки, которые поступают, в том же темпе, на входы следующих устройств или на выход конвейера. Можно сказать, что потоки данных, поступающих на арифметический конвейер, «протекают» сквозь устройства, образуя новые потоки промежуточных и окончательных результатов. Идеальный арифметический конвейер – конвейер, в котором темп поступления данных в потоках равен 1 такту.

Для построения арифметических конвейеров используются объекты типа AUstream – потоковые арифметические устройства, которые, в свою очередь, тоже бывают нескольких типов. Конкретный экземпляр объекта (далее AU) объявляется в разделе деклараций с указанием имени и всей необходимой информации для его построения. Что необходимо «знать» транслятору для создания AU?

  • Какова ширина вектора AU. Конвейер может быть векторным, то есть обрабатывать данные параллельно на нескольких «нитках». Ширина вектора – число таких «ниток».
  • Какие данные поступают в AU (описание входных переменных-потоков).
  • Какие вычисления выполнять в AU (описание типов арифметических устройств).
  • Какие выходные переменные выдавать из AU.

Описание входных переменных.

Входными переменными могут быть переменные типа ram, reg, index, а также выходные переменные (потоки) других AU. Объявление этих переменных в схеме должно предшествовать объявлению AU. Каждая входная переменная в AU рассматривается как поток входных данных, т.е. поступление на каждом такте значений этой переменной, в течение определенного интервала времени. Поэтому каждая входная переменная в AU должна быть определена как поток с указанием типа потока. Тип потока – это тип данных внутри потока и характер потока.

Тип данных – это форма представления данных, т.е. вещественные или целые числа образуют данный поток, и их разрядность. Разрядность данных должна быть известна из внешней декларации переменных, поэтому в объявлении AU указывается только формат - float или int. Поток типа float может быть и 32, и 64, и 128 бит, а поток типа int – произвольной разрядности.

Если ширина вектора AU равна 1, то входными переменными могут быть только скалярные регистры и биты. К скалярным регистрам относится также векторный регистр с указанием индекса или с указанием диапазона (в последнем случае, входная переменная рассматривается, как набор скалярных регистров с индексами, соответствующими указанному диапазону).

Если ширина вектора AU больше 1, то входными переменными могут быть скалярные регистры, биты и векторные регистры, с равной шириной вектора или с указанием индекса.

Характер потока – постоянный (const) или переменный. Эта характеристика важна для синхронизации потоков внутри AU. Постоянным считается поток, элементы которого не меняют своего значения за весь период рабочего цикла AU. Рабочий цикл AU – это период времени между поступлением первых элементов входных данных и выходом последних элементов результатов вычислений.

Примеры объявлений входных потоков:

float x1,
x2[0], x3;
float const c1, c2, c3(0:3);
int const rgm;

ЗАМЕЧАНИЕ. В двухпортовой памяти, используемой в Автокоде, довольно часто один порт отдается на чтение исходных данных, а другой – на запись результатов вычислений. В арифметических объектах по умолчанию считается, что чтение – по порту ‘b’, запись – по порту ‘a’. Поэтому при объявлении в AU входной переменной типа ram, если данные из массива читаются по порту ‘b’, можно указывать только имя. Т.е. для транслятора объявления переменной типа ram x1 и x1.doutb являются идентичными. При чтении данных по порту ‘a’ должна указываться переменная x1.douta.

Типы арифметических устройств.

В зависимости от характера вычислений или от сферы применения, арифметические устройства делятся на нескольких типов. Для всех типов арифметических устройств, кроме базового, в объявлении после ключевого выражения "AUstream::" должно идти указание типа AU.

Арифметическое устройство базового типа.

Выполняет поэлементные вычисления между массивами и другими переменными. Результатом вычислений является один или более выходных потоков. Длина выходных потоков равна длине входных потоков. Объявление AU базового типа в общем случае состоит из заголовка и тела объявления и имеет следующий вид:

AUstream::(id2, idN)  SIZE  <name>(BL)
	{
	   Объявления входных потоков;
	    id1 = выражение1;
	    id2 = выражение2;
	     . . . . .
	    idN = выражениеN;
	}

В заголовке после ключевого слова «AUstream::» в круглых скобках через запятую указываются идентификаторы выходных потоков. В случае, когда выходной поток один, скобки можно оставить пустыми, а выходному потоку в теле объявлений дать идентификатор AU – <name>.

После скобок указывается базовая разрядность вычислений SIZE (32, 64 или 128). Базовая разрядность означает, что переменные типа float должны иметь разрядность, равную SIZE, а типа int – не более SIZE.

Далее указывается идентификатор AUname и, возможно, коэффициент векторности вычислений BL. Если конвейер имеет ширину вектора 1, то есть состоит из единственной "нитки", число "ниток" - BL - можно не указывать.

Тело объявления заключено в фигурные скобки (каждая из которых пишется на отдельной строке) и состоит из операторов объявления входных потоков и блока операторов присваивания. Каждый оператор должен начинаться с новой строки и заканчиваться ";". Переносы строк допускаются.

Операторы присваивания могут быть безусловные, имеющие вид:

id1 = <алгебраическое_выражение>;

или условные типа when, имеющие вид:

id2 = (<условие1>) ? x1 : (<условие2>) ? x2 : (<условие3>) ? … : xN;

где id1 и id2 – идентификаторы результатов вычислений соответствующих выражений, и являются, по сути, именами новых потоков. Тип нового потока определяется из анализа операндов, участвующих в выражении справа от знака =, поэтому отдельно объявлять эти внутренние переменные-потоки не надо. Из этого следует: все имена создаваемых потоков должны быть уникальны для данного AU, использовать их можно только в нижестоящих вычислениях, и все операнды в правой части выражения (для условного оператора – это переменные x1, x2, … xN) должны иметь один тип данных.

В безусловных операторах присваивания допустимы следующие операции:

"+", "-", "*", "/" ;

pow(a, N) – возведение a в степень N;

sqrt(a) – извлечение квадратного корня из a;

min(a,b); max(a,b) – выбор минимального или максимального из a и b;

(float)a, (int)b – перевод a или b в другой формат.

В условных операторах допустимы операции:

"==", "!=", ">", ">=", "<", "<=", "&&", "||"

Количество выполняемых операций в AU лимитируется только ресурсами ПЛИС.

В качестве операндов можно использовать и числовые константы (для вещественных чисел точка обязательна).

В случае, когда в AU вычисление можно записать одним выражением и все операнды имеют одинаковый тип потока, то объявление может ограничиваться только заголовком вида:

AUstream::(<тип_потока> (<выражение>)) SIZE <name>(BL)

Вычисляемое выражение заключается в круглые скобки, а выходному потоку присваивается имя устройства name.

Арифметическое устройство типа PORT.

Этот тип устройств по характеру вычислений похож на базовый тип, но работает в несколько специфических условиях. Данное AU включается в схему между внешним интерфейсом схемы и массивами памяти, определенными в объекте Memory как входные (in или inout) параметры-массивы, тем самым, как исключение, выполняет вычисления вне рабочего цикла схемы.

Может быть объявлен в схеме только один раз и имеет вид:

AUstream::PORT(ram1, . . .)
{
  Объявления входных потоков;
  ram1 = выражение1;
	      . . . . .
}

В заголовке объявления, в качестве выходных потоков, указываются переменные типа ram, определенные в объекте Memory. Базовая разрядность вычислений, имя объекта и ширина вектора AU не задаются (ширина вектора может быть равна только 1, разрядность определяется из внешней декларации переменных и платформенной разрядности схемы, а имя устройству дается по типу AU).

Входными переменными могут быть внутрисхемные скалярные регистры и биты, интерфейсный регистр входных данных DI и интерфейсный регистр адреса ADDR. Все входные переменные, кроме ADDR, должны присутствовать в объявлении входных потоков. Все входные потоки, кроме DI, должны иметь тип const.

Запись блока операторов подчиняется тем же правилам, что и в AU базового типа, кроме одного ограничения: идентификатор конкретного выходного потока может присутствовать в блоке только один раз слева от знака ‘=’.

Более подробно об этом типе AU будет рассказано при анализе примера в следующей главе.

Арифметические устройства, выполняющие операции редукции.

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

MAX – нахождение максимального элемента в массиве.
MIN – нахождение минимального элемента в массиве.

Объявление объектов этих типов записывается в виде:

AUstream::<MAX|MIN>(<формат_данных> arr, BL)  SIZE <name>

Здесь и далее приняты следующие условные обозначения:

формат_данных –  float или int;
arr имя входного потока;
BL ширина вектора входного потока;
SIZE разрядность данных в потоке;
name идентификатор AU.
SUMW или SUM скалярное суммирование массива и заданного числа;
SUBW или SUB вычитание элементов массива из заданного числа.

Объявление объектов этих типов записывается в виде:

AUstream::<тип_AU>(<формат_данных> X0, arr, BL) SIZE <name>

Где X0 – начальное значение, к которому прибавляются, или из которого вычитаются, все элементы массива arr. Может быть либо числовой константой, либо скалярным регистром разрядности SIZE.

Разница между SUMW и SUM, или между SUBW и SUB, касается только вычислений данных типа float, и состоит в способе реализации: выполнение редукции с ожиданием результата и без ожидания результата. В первом случае следующее вычисление можно запустить, только дождавшись на выходе появления предыдущего результата (экономная по ресурсам операция). Во втором – закончив подачу одного входного потока и «передохнув» 1 такт, можно подавать следующий поток (операция, очень затратная по ресурсам). Поэтому, если нет особой необходимости, использовать нужно типы SUMW и SUBW.

2.2. Использование арифметических устройств в схеме.

После объявления объекта типа AUstream транслятор сам создаст арифметическое устройство, оформив его в виде компонента, и включит в основную схему соединения портов компонента с регистрами схемы. Для доступа к результатам вычислений и к сигналам управления AU, в схеме будут созданы регистры доступа. Для AU типа PORT создаваемые регистры будут служебными (в именах будет присутствовать префикс _avt), использовать их будет только транслятор для записи в соответствующие блоки векторной памяти. В остальных случаях обращение к этим регистрам записываются подобно обращениям к регистрам доступа массива.

Обращение к регистрам доступа AU с именем name.

Обращение к выходным результатам:

name.out   // выходной регистр результата (разрядность базовая)
name.rdy   // выходной регистр готовности результата (разрядность =1)

В случае выдачи из AU нескольких результатов (напр. res2 и resN), то это будут, соответственно, res2.out, res2.rdy и resN.out, resN.rdy. Каждый поток выходящих из AU результатов сопровождается своим сигналом готовности, который гарантирует наличие осмысленных данных в выходном потоке на весь период, когда его значение равно 1.

Сигнал управления запуском AU на вычисления:

name.we   // входной регистр разрешения вычислений (разрядность =1)

Значение данной переменной программист формирует сам. В секции начального сброса name.we надо инициализировать нулем. Запуск AU на вычисления и завершение работы записываются в разделе состояний. Для этого в одном из состояний записывается цикл чтения массивов, участвующих в данных вычислениях. Для синхронного чтения нескольких массивов или нескольких блоков одного массива в комбинационной части схемы, соответствующим адресным регистрам присваивается переменная типа index. В разделе состояний следует организовать цикл чтения, меняя значение этой переменной. На весь период чтения сигнал разрешения устанавливается в 1, а по окончании устанавливается опять в 0.

Если среди входных данных в AU подается поток результатов другого AU, то регистр доступа name.we не создается, т.к. его роль будет играть сигнал готовности соответствующего потока результатов другого AU.

Все регистры доступа (.out, .rdy, .we) будут скалярными, если коэффициент векторности вычислений равен 1, и векторными, если он больше 1.

Прежде, чем перейти к разбору примеров, использующих объекты типа AUstream, отметим следующее. Построение арифметических конвейеров вручную – не только самая трудоемкая, но и самая ошибкоемкая часть написания схем. К обычным ошибкам, которые случаются в любой программе, добавляются ошибки схемотехнического характера (если, вооружившись калькулятором, подсчитывать для синхронизации потоков задержки десятков, а иногда и более сотни операций, то ошибки практически гарантированы). При этом найти такую ошибку, анализируя полученный результат, практически невозможно, а средств для отладки, кроме задания упрощенных входных параметров или написания нескольких схем с частичными вычислениями, нет.

При использовании объектов AUstream языка Автокод Stream правильная синхронизация внутри арифметического устройства гарантирована. А форма записи выражений в теле объявления максимально приближена к языку C, что дает возможность отладить весь объем вычислений в обычной программе и перенести его потом методом «Copy-Paste» в объявление объекта в схеме.

2.3. Схема вычисления определенного интеграла методом трапеций (пример 3).

Напомним, что разбор именно этого примера в Части 1 вынудил нас усомниться в пригодности базового подмножества нашего языка для практического использования. Построение конвейера шириной 8 «нитей» для суммирования содержимого 8-блочного массива потребовало напряжения наших умственных способностей, совершенно не сопоставимого с простотой решаемой задачи. Отметим, что в реальных схемах именно построение систем согласованных между собой вычислительных конвейеров является основным приемом проектирования.

Использование арифметических объектов позволяет автоматизировать эту работу в очень значительной степени.

Ниже приводится схема примера 3 Части 1, записанная на Автокоде Stream, но для чисел с плавающей точкой. Это сделано специально, чтобы показать, что различия в записи схем при такой замене коснутся только изменения формата данных в объявлении арифметического объекта (записи аналогичных схем на Автокоде HDL в примерах 3 и 7 Части 1 различались достаточно сильно).

Начало схемы

   mainprogram32
   Memory::(inout array)
   BlockRegister::(L>>3)
   endprogram

   declare 
     index 24 i           
     reg 24 L, last_addr
     ram 32 array(rams, 8, 16384)
     AUstream::PORT(array)
      {
            float DI;
            int const last_addr;
            half = DI/2.0;
            array = (ADDR==0 || ADDR==last_addr) ? half : DI;
      }
     AUstream::SUM(float 0, array, 8) 32 sum
   enddeclare
   array.addrb = i
   last_addr = argvblock[0](23:0) - 1
   Background:
    {
       [ 
          sum.we = 0
          i = 0
       ]
    }
   cycle_read:
    {
        if ( i < L )
             sum.we = 1
             i++
             next cycle_read
        else
             sum.we = 0
             i = 0
        endif
    }
   wait_finish:
    {
       if ( sum.rdy == 0 )
             next wait_finish
       else
             return sum.out
       endif
    }

Конец схемы.

В разделе деклараций объявлены арифметические объекты AUstream типа PORT и типа SUMW.

Рассмотрим первое объявление:

AUstream::PORT(array)
{
      float DI;
      int const last_addr;
      half = DI/2.0;
      array = (ADDR==0 || ADDR==last_addr) ? half : DI;
}

Данная запись означает включение в схему объекта «AUstream» типа «PORT». Почему используется этот тип, а не базовый? Для вычисления интеграла перед суммированием массива необходимо разделить пополам первый и последний элементы массива. В выбранной нами модели взаимодействия рабочий цикл схемы запускается после получения сигнала запуска со стороны управляющей программы, когда все входные параметры уже переданы в схему. Поэтому для выполнения деления перед суммированием, необходимо будет, сначала считать из памяти первый и последний элементы массива, разделить их, используя AU базового типа, а потом сохранить в памяти результаты вычислений. И только после этого переходить к основному циклу чтения массива.

Использование AU типа PORT позволяет выполнить эти деления вне рабочего цикла схемы, непосредственно перед начальной записью массива в схему. Это сокращает время работы схемы, упрощает логику управления, и заметно экономит оборудование (если, например, операция деления применялась бы к каждому элементу массива, то AU пришлось заказывать в 8 нитей, по ширине вектора массива array, и устройств было бы 8, а не одно).

Входными переменными в данном объявлении являются интерфейсный регистр данных – DI (в данном примере типа float) и адрес последнего элемента – last_addr типа int const (о том, где и как мы его формируем, будет сказано чуть ниже). Во втором выражении присутствует еще переменная ADDR – интерфейсный регистр адреса. Объявлять ее не надо, т.к. размерность, формат данных и характер потока ADDR известны.

Встретив объявление AUstream::PORT, транслятор создаст компонент, выполняющий указанные вычисления, и регистры доступа. Кроме результатов вычислений и сигнала готовности, из компонента необходимо выдавать адреса, поскольку их придется синхронизировать относительно выходных данных. Кроме того, транслятор «обязует» программный объект Memory, для записи данных в память array, создавать соединения памяти не с интерфейсными регистрами, а с этими выходными регистрами доступа.

Запись:

AUstream::SUMW(float 0, array, 8) 32 sum
означает включение в схему объекта «AUstream» типа «SUMW». Данное потоковое арифметическое устройство (назовем его sum) будет суммировать массив вещественных чисел разрядностью 32 бита, поступающих в устройство через 8 "нитей".

Имя входного массива array – в декларации это имя принадлежит переменной типа ram. Вне объявлений объектов AUstream, как и в базовом языке, обращение к блокам памяти возможно только через его регистры доступа (.douta, .doutb и т.д.). Здесь же, указывая просто имя переменной, мы по умолчанию подразумеваем регистр доступа array.doutb. Если, по необходимости, для чтения используется порт a, то задавать имя надо в явном виде, как array.douta.

Для обращений к устройству sum в схеме образуются 3 регистра доступа:

sum.we  — разрешение суммирования,
sum.out — результат суммирования,
sum.rdy — признак готовности результата.

Теперь для организации в схеме правильного суммирования массива, программисту остается только сформировать поток входных данных, сопроводив его сигналом разрешения sum.we, а потом дождаться результата суммирования sum.out, который будет готов при sum.rdy = 1. Эти действия будут записаны в разделе состояний.

Но сначала перечислим все предварительные действия, необходимые для работы схемы:

  • обеспечим прием/выдачу массива array, расположенного в 8 блоках векторной памяти: запись
    Memory(::inout array);
  • примем в схему один параметр – длину массива. В примере 2 мы принимали этот параметр, используя объект Register. Но, как следует из описания данного объекта, его первый аргумент фактически приходит в схему самым последним параметром, т.к. является аргументом функции logic_init вызывающей программы, которая запускает рабочий цикл схемы. А в данном примере длину массива надо получить гораздо раньше, чтобы знать адрес последнего элемента параметра-массива в вычислениях, определенных в объекте AUstream::PORT. Записав объявление:
    BlockRegister(L>>3)
    мы решим сразу две задачи:
    1. обеспечим появление параметра на векторном регистре argvblock в нужное время, и, записав в комбинационной части присваивание
    last_addr = argvblock[0](23:0) - 1
    вовремя сформируем адрес последнего элемента массива;
    2. в момент запуска рабочего цикла схемы параметр перепишется в переменную L со сдвигом вправо на 3 разряда (деление на 8), что даст нам верхнюю границу в цикле чтения массива array, ширина вектора которого равна 8;
  • заведем переменную i – счетчик адресов по чтению, и соединим его в комбинационной части со всеми 8-ю регистрами вектора array.addrb. Меняя значение регистра i, на автомате будем менять значения адресов во всем векторе array.addrb;
  • в секции начального сброса установим в ноль sum.we и i. Регистр разрешения записи в AU, подобно признаку записи в память, должен быть инициализирован нулем, если только ему ничего не было присвоено в комбинационной части. Счетчик адресов i инициализируем нулем, чтобы не тратить на это время в дальнейшем.

А теперь запишем действия в разделе состояний (их всего 2).

  1. В это состояние (метка cycle_read) мы попадаем, имея sum.we = 0 и i = 0. Пока выполняется условие i < L, будем увеличивать счетчик на 1, выполнять sum.we = 1 и возвращаться в это же состояние. Т.е. на 2-м такте этого состояния sum.we уже станет =1, i станет = 1, а на выходе из array появятся 8 чисел, лежащих в памяти по адресу 0. На 3-ем такте sum.we = 1 , i = 2, на выходе array числа по адресу 1 и т.д. Последний раз в это состояние попадаем, когда sum.we = 1, i = L, на выходе array – числа по адресу L-1. Здесь выполнится ветка else, в которой восстанавливаем исходное значение i = 0 , пишем sum.we = 0 (sum.we станет равным 0 на следующем такте) и покидаем первое состояние. Таким образом, мы установили разрешение записи на весь период выдачи на каждом такте потока данных, начиная с 0-го адреса по L-1. И сигнал sum.we и поток данных array являются входными параметрами для арифметического устройства sum, который выполнит необходимое суммирование.
  2. Во втором состоянии (метка wait_finish) происходит ожидание результата суммирования sum.out. При выполнении условия sum.rdy = 1 (признак готовности результата) выполняется оператор return sum.out, который сохранит полученный результат для выдачи в управляющую программу и переведет схему в состояние ожидания.

Как видно из схемы, чтобы выполнить аналогичные вычисления с массивом чисел с фиксированной точкой, достаточно изменить формат данных в арифметических объектах с float на int.

Управляющая программа для данного примера выглядит так:

#include <stdio.h>
#include <avtokod/comm.h>
#define L 128
	int main( void )
{
	int i;
	float result;
	static float array[L];
	int ans, len=L;

	init_coprocessor( 0, 0 );	
	for ( i = 0; i < len; i++ ) array[i] = i+1;
	logic_put_block_reg(&len,1);
	logic_put( 0, 0, array, len );
	logic_init( 0 );
	logic_wait( 0, &ans );
	logic_result((int*)(&result));
	printf( "result: %f\n", result ); // = 8191.5
	for ( i = 0; i <len; i++ ) array[i] = -1;
	logic_get( 0, 0, array, L );
	for ( i = 0; i < len; i++ ) 
	 {
	  if ( array[i] != i +1) printf( "0x%x %f\n", i, array[i] );
	}
	printf( "Over\n" ); 
	return 0; 
}

При запуске управляющей программы, на печать должны выдаться результат суммирования – 8191.5 и измененные значения 1-го и последнего элемента массива.

2.4. Схема, выполняющая произвольные вычисления с массивами вещественных чисел заданной длины (пример 4).

В данном примере приведена схема, выполняющая совершенно произвольные вычисления с массивами и переданными в схему параметрами-скалярами. Результатами должны стать массив – результат одних вычислений, и скаляр – результат других вычислений.

Начало схемы:

mainprogram32
Memory::(in A, in B, inout C)
BlockRegister::(k1, k2, k3, k4)
Register::(L>>3)
endprogram
declare
          index 24 addr_read
          ram   32 A(rams, 8,16384), B(rams, 8,16384), C(rams, 8,16384)
          reg   32 k1, k2, k3, k4
          reg   24 L,  addr_write
          AUstream::(new_C, res) 32 expr(8)
          {
              float A, B, C;
              float const k1, k2, k3, k4;
              id1  = (A + C)*sqrt(B) + k1;
              new_C = (B*k2 - C*k3)*id1;
              res  = new_C/(C +1.0) + k4;
          }
          AUstream::SUMW(float 0, res.out, 8) 32 sum
enddeclare
 {A.addrb, B.addrb, C.addrb} = addr_read
 C.addra = addr_write
 C.dina = new_C.out
 C.wea = new_C.rdy
Background:
  {
       [ 
           expr.we = 0
           addr_read = 0
       ]
        if(new_C.rdy[0] == 0)
            addr_write = 0
        else
            addr_write++
        endif
  }
cycle_read:
  {
    if(addr_read < L)
        addr_read++
        expr.we = 1
        next cycle_read
    else
        addr_read = 0
        expr.we = 0
    endif
  }
wait_finish:
  {
     if(sum.rdy == 0)
        next wait_finish
     else
        return sum.out
     endif
  }

Конец схемы.

В заголовке мы описываем параметры, участвующие в обмене данными между схемой и управляющей программой:

  • массивы A, B и C (два входных, а последний входной и выходной);
  • 4 параметра, передаваемые одним блоком – k1, k2, k3 и k4;
  • 1 параметр L – длина массивов, деленная на 8 (такова векторность вычислений).

В разделе деклараций объявляем два объекта типа AUstream.

  1. AUstream::(new_C, res) 32 expr(8)

    В заголовке объявления задаем имя AUexpr, базовую разрядность – 32, векторность вычислений – 8, в первых скобках указываем имена выходных потоков-результатов new_C и res2.

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

    Для обращений к устройству expr в схеме образуются 5 векторных регистров доступа (длина вектора =8):
    expr.we разрешение вычислений,
    new_C.out, res.out два результата вычислений,
    new_C.rdy, res.rdy –  два признака готовности соответствующих результатов.

  2. AUstream::SUMW(float 0, res.out, 8) 32 sum

    Объявление арифметического устройства типа SUMW. В отличие от примера 3 входным потоком является не переменная типа ram, а поток результатов предыдущих вычислений – векторный регистр res.out. Поэтому для обращения к устройству sum в схеме образуются только два скалярных регистра доступа:
    sum.out – результат суммирования,
    sum.rdy  признак готовности результата.

В качестве сигнала разрешения суммирования используется скалярный регистр res.rdy[0].

В комбинационной части схемы на все регистры адресов чтения трех массивов A, B и C подаем счетчик адресов addr_read. Обеспечиваем запись в память C результатов вычислений, подавая:
— на вход данныхC.dina результат вычислений new_C.out,
на признак записи C.wea готовность результатов new_C.rdy,
на входы адресов C.addra –  счетчик адресов записи addr_write, формировать значения на котором будем в тактированной части Background.

В разделе Background формируем адреса записи для массива C: пока нет результатов вычислений (new_C.rdy[0]= 0) , счетчик адресов записи addr_write равен 0. С появлением на new_C.rdy[0] единицы, addr_write начинает увеличиваться на 1, тем самым, обеспечивая запись 1-го результата по адресу 0, 2-го – по адресу 1 и т.д. Последний результат запишется по адресу L-1, после чего запись закроется, т.к. new_C.rdy[0] станет равным 0.

Раздел состояний записывается точно так же, как и в предыдущем примере, только сигнал разрешения формируется для арифметического устройства expr. Для сумматора sum разрешение записи появится в нужное время автоматически. Поскольку окончательная сумма сформируется заведомо позже записи в массив C, момент завершения всей работы определяется по условию sum.rdy= 1.

Управляющая программа для нашего примера выглядит так:

#include <stdio.h>
#include <avtokod/comm.h>
#define L 128
	int main( void )
{
	int i, ans;
	float param = (0.2, 0.4, 0.6, 0.8); 
	float result;
	static float A[L], B[L], C[L];
	init_coprocessor( 0, 0 );
	for ( i = 0; i < L; i++ )
	  { 
	    A[i] = i;
	    B[i] = i+1;
	    C[i] = i+2;
	  }
	logic_put_block_reg(param, 4);
	logic_put( 0, 0, A, L );
	logic_put( 1, 0, B, L );
	logic_put( 2, 0, C, L );
	logic_init( L );
	logic_wait( 0, &ans );
	logic_result((int*)(&result) );
	printf( "result: %f\n", result );
	logic_get( 2, 0, C, L );
	for ( i = 0; i < L; i++ ) printf( "%f\n", C[i] );
	printf( "Over\n" ); 
	return 0; 
}

В управляющей программе передача параметров-скаляров происходит и через вызов функции logic_init() (в схеме – это параметр argv[0]), и через вызов функции logic_put_block_reg() (в схеме – это векторный регистр argvblock(0:3)), реализующей передачу блока параметров-скаляров в схему. В программе этот блок создается в виде массива, размером, равным числу параметров, и порядок элементов в массиве должен соответствовать порядку перечисления входных параметров в объявлении BlockRegister в схеме. Аргументами функции являются указатель на массив и размер массива.

Для схемы, написанной на Автокоде Stream, трансляция программы и схемы, прожиг и запуск приложения на исполнение остается таким же, как и для Автокода HDL:

fpga_compile_SIZE -o primerX primerX.c
avts -vivado primerX.avt
burn
mpirun -np 1 primerX

где SIZE – разрядность базовой платформы (32, 64 или 128).

Все компоненты, созданные при трансляция схемы, оформляются транслятором в виде отдельного файла (текст написан на языке VHDL), который при сборке добавляется к файлу с текстом основной схемы vector_proc_SIZE.

3. Принципы построения эффективных схем.

3.1. За счет чего схемная реализация работает быстрее программной.

Начнем с грубой оценки. Процессор (Pentium, Opteron, …) является цифровой электронной схемой на базе транзисторов, как и та схема сопроцессора, которую мы сначала описываем на Автокоде, а затем создаем в кремнии, загружая результат трансляции этого описания в микросхему программируемой логики. Чтобы одна цифровая электронная схема (наш сопроцессор) работала быстрее другой (стандартный процессор общего назначения), она, казалось бы, должна либо содержать больше исходного материала (транзисторов) для построения большего числа арифметических устройств, либо иметь более высокую частоту работы этих устройств. В данном случае ни одно из этих утверждений не верно – по «количеству исходного материала» микросхема программируемой логики близка к современному микропроцессору общего назначения, а по тактовой частоте отстает от него примерно на порядок (в 7-20 раз). Тем самым, грубая оценка показывает, что вместо выигрыша схемная реализация, казалось бы, должна давать заметный скоростной проигрыш. Тем не менее, ускорение реальных расчетов при грамотной схемной реализации – экспериментальный факт. Так откуда же все-таки оно берется? Какую именно содержательную информацию о свойствах алгоритма, утрачиваемую при записи на С или Фортране, именно этот язык позволяет донести до аппаратуры, заставляя ее работать быстрее? Не поняв этого, хотя бы в общих чертах, надеяться на ускорение схемной реализации расчета, по сравнению с программной, бессмысленно.

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

Попробуйте записать Ваш расчет на таком языке, как Perl, Python или Java, и сравните быстродействие полученной программы с быстродействием программы, написанной на Фортране или С. Программа на интерпретируемом языке будет работать в разы медленнее. При этом полезной, с точки зрения программиста, работы она выполняет столько же. Причина этого, как мы хорошо знаем, в том, что при выполнении программы на интерпретируемом языке процессор занят, в основном, работой «бесполезной». Он лишь изредка выполняет записанные в программе арифметические вычисления, тратя основное время на выяснение того, какие именно вычисления, и с какими данными, надо выполнять в данный момент, то есть на интерпретацию программы.

Компилируя программу, мы выполняем эту «бесполезную» работу заранее, избавляя процессор от нее на фазе выполнения программы. Выполняя скомпилированную программу, процессор работает не быстрее, но с гораздо более высоким к.п.д., что и ускоряет расчет.

Вся ли «бесполезная» работа выполняется заранее при компиляции программы, написанной на традиционном языке программирования? Снова используем грубую оценку. Сравним реально достигаемое при выполнении программы быстродействие в «полезных» арифметических операциях с заявленным производителем процессора пиковым быстродействием. Получаемая величина «к.п.д.» для подавляющего большинства реальных программ вычислительного характера будет лежать в диапазоне от 1 до 15 процентов, чаще всего – в районе 3-5 процентов. Следовательно, даже выполняя заранее скомпилированную программу, процессор тратит практически все время на «бесполезную» работу по подготовке к вычислениям, и лишь малую его часть – на сами вычисления.

Процессор по-прежнему «не знает заранее», что именно ему предстоит делать, и интерпретирует программу, команду за командой, всякий раз выясняя, каких именно «полезных» действий от него «потребуют» на этот раз. Для того, чтобы исключить (или многократно снизить) уже эти затраты, программу, похоже, надо «скомпилировать еще раз».

Простейший пример такого рода «неожиданности» - выборка значения из памяти.

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

Между тем, в упоминавшихся выше примерах построенные нами схемы, как правило, не простаивают в ожидании выбираемых из памяти значений ни одного такта. За счет конвейерной организации схемы, необходимые значения просто оказываются на входе соответствующего арифметического устройства именно тогда, когда это устройство способно их принять. Достигается это отказом от самого понятия программы, так или иначе интерпретируемой процессором. Записанные на Автокоде схемы процессорами не являются, и никаких программ не выполняют. Информация о том, какие именно действия, с какими значениями и в каком порядке выполнять, не закодирована в программе, которой нет, а просто учтена (точнее, выражена) непосредственно в структуре заранее построенной схемы.

Процессору, чтобы обеспечить сложение двух чисел, надо:

  • выбрать и раскодировать очередную команду,
  • «сообразить», чему равны адреса слагаемых,
  • доставить слагаемые в сумматор,
  • запустить сумматор,
  • ...

В то же время, схеме, в которой две разных памяти заранее присоединены своими выходами к входам сумматора, а регистрами адреса – к соответствующим счетчикам, всю эту «бесполезную» подготовительную работу делать не надо. На очередном такте необходимое сложение запустится само. Это и есть упомянутая нами выше гипотетическая «компиляция программы еще раз», или, выражаясь более правильно, прямая схемная реализация вычислительной процедуры. Нет ничего удивительного в том, что, двигаясь этим путем, можно ускориться, из расчета на один такт рабочей частоты, в десятки и сотни раз, так что, даже с поправкой на гораздо меньшие, чем у универсальных процессоров, тактовые частоты, итоговое ускорение все еще получается многократным.

Таким образом, ускорение расчета при схемной реализации вычислительной процедуры по сравнению с ее программной реализацией получается за счет отказа от интерпретации программы (как и от самой программы вообще). Структура вычислительной процедуры непосредственно отображается в структуру схемы. Если это отображение выполнено удачно, схема будет иметь стопроцентный к.п.д., то есть на каждом такте выполнять «полезные» вычисления. Выбор для записи схем такого своеобразного языка, как Автокод Stream, делает возможным выполнить упомянутое отображение правильно, но сам по себе ни в коей мере этой правильности не гарантирует.

Короче говоря, без молотка забить гвоздь очень трудно, иногда - невозможно. Молотком гвоздь можно забить. Но можно - и не забить.

3.2. Как строить эффективные схемы.

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

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

Проектируя схему, следует стараться мыслить не шагами выполнения алгоритма, а тактами (моментами дискретного времени), потоками обрабатываемых данных и функциональными устройствами, такими, как блоки памяти, сумматоры и т. п.

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

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

Чтобы понять, на чем основано это управление, рассмотрим основные принципы работы типичного функционального устройства, например, сумматора чисел с плавающей точкой, имеющего два входа и один выход. Сумматор работает постоянно: каждый такт он принимает числа, поступающие на вход, и каждый такт выдает на выход результаты суммирования. При этом сам процесс суммирования занимает, в общем случае, несколько тактов (так называемая задержка устройства). То есть, для двух конкретных чисел, их сумма появится на выходе сумматора через несколько тактов. Поэтому в каждом функциональном устройстве, кроме входов и выхода данных, имеются еще два управляющих сигнала: входной сигнал разрешения и выходной сигнал готовности. Принимая на вход «правильные» данные (сопровождаемые активным сигналом разрешения), сумматор, спустя задержку, активирует выходной сигнал готовности, который будет сопровождать «правильные» результаты. Далее сигнал готовности одного устройства предполагается непосредственно подавать в качестве сигнала разрешения на следующее, потребляющее результаты его работы. Такой принцип построения вычислений в схеме называется конвейерным.

Второй важный аспект построения эффективных схем вычислительного характера – необходимость строить схемы векторно-конвейерного типа. При построении схем вычислительного характера обычно стараются максимально использовать векторную обработку: пропускать некоторое поле величин не через одно, а через 2, 4 или 8 идентичных функциональных устройств, работающих одновременно.

А теперь постараемся понять, что определяет время вычислений в схеме. Для этого сравним примеры 3 и 4, подробно рассмотренные выше. В примере 3 мы суммировали исходный массив, в примере 4 мы выполнили ряд вычислений, а затем суммировали полученный массив результатов.

Время вычислений в тактах:

пример 3 = LEN/8 + 24 (задержка AU sum).

пример 4 = LEN/8 + 70 (задержка AU expr) + 24 (задержка AU sum).

где: LEN/8 – длина исходных массивов, деленная на ширину вектора ram.

Суммарная задержка арифметических устройств (для строгости, к ней надо прибавить еще 2 такта на первую выборку чисел из памяти) называется разгоном конвейера.

Поскольку длина массивов на порядки больше времени разгона конвейера, можно утверждать, что время вычислений примера 3 и примера 4 практически одинаково и определяется числом тактов, потраченных на чтение массива (длина массива, деленная на векторность вычислений). Если бы в примере 4 мы разделили все вычисления на два цикла: в одном выполнили все поэлементные вычисления, сохранили результаты в памяти, а в следующем цикле, по сути, выполнили пример 3, то затраченное время было бы таким: LEN/8 + 70 (задержка expr) + LEN/8 + 24 (задержка sum). То есть время вычислений увеличилось бы в два раза (для программы, выполняемой на универсальном процессоре, такое разбиение не изменило бы время вычислений).

Тогда формулу, определяющую затраты времени на вычисления в тактах, можно записать так:

ITERS * LEN / BLOCK_WIDTH + delta
где
ITERS количество последовательных циклов чтения массивов;
LEN длина массива;
BLOCK_WIDTH –  число элементов массива, считываемых в одном такте;
delta разгон конвейера.

Таким образом, для повышения эффективности вычислений необходимо:
  1. уменьшать ITERS – т.е. увеличивать глубину конвейера (как можно больше вычислений выполнять в одном цикле чтения);
  2. увеличивать BLOCK_WIDTH – т.е. повышать векторность вычислений.

3.3. Борьба за экономию ресурсов.

К сожалению, ресурсы ПЛИС достаточно ограничены. Суммарный объем памяти, из которой формируются блоки векторной памяти ram, составляет примерно 2 – 15 Мбайт. Для вычислений больших объемов данных приходится закачивать в схему данные порциями, что в значительной степени увеличивает время вычислений за счет ожидания очередной порции. В Автокоде предусмотрен тип векторной памяти ramd, который позволяет, за счет двойной буферизации, совмещать обработку очередной порции данных с подкачкой следующей или откачкой результатов вычисления предыдущей.

Объем логики в ПЛИС хотя и соизмерим с процессорным, но в конвейерных вычислениях для каждой арифметической операции с плавающей точкой создается свое функциональное устройство, расходующее весомую часть вычислительных ресурсов. Поэтому при написании схемы всегда надо помнить об экономии. Вот несколько советов:

  • все присваивания потоков данных производить по возможности в комбинационной части;
  • для записи вычислений во вложенных циклах соблюдать следующее правило: вычисления во внешнем и внутреннем циклах записывать в разных объектах типа AUstream, задавая коэффициент векторности вычислений для внешнего цикла равным единице, а для внутреннего – максимально возможным;
  • однократную арифметическую операцию между параметрами-скалярами лучше выполнить в программе на процессоре, а в схему передать результат операции, как еще один параметр;
  • при необходимости выполнить однократную арифметическую операцию между параметром-массивом и константой, следует использовать объект AUstream типа PORT, что позволит сэкономить ресурсы и время вычислений.
 
 
 
 
 
 
 
  Тел. +7(499)220-79-72; E-mail: inform@kiam.ru