МВС-1000
/ Исследования
/ Гибридный реконфигурируемый вычислитель
/ Руководство программиста
Андреев С. С., Дбар С. А., Лацис А. О., Плоткина Е. А.
Гибридный реконфигурируемый вычислитель.
Руководство программиста.
Часть 3. Расширенные возможности языка.
Сведения, изложенные в Части 1 и Части 2 настоящего Руководства, в принципе достаточны для разработки реальных схем. Но скорее именно в принципе, чем на практике. Уровень базового подмножества Автокода – это примерно уровень языка ассемблера в иерархии уровней языков традиционного программирования. Все принципиальные возможности уже есть, но компактность записи программы оставляет желать лучшего. В самых разных исходных текстах без труда можно заметить практически одинаковые «типовые фрагменты», которые следовало бы уметь записывать одним – двумя словами.
Спроектировать язык принципиально более высокого уровня абстракции и надстроить его над Автокодом, подобно тому, как язык C надстраивается над языком ассемблера, пока не представляется возможным. Чтобы понять, каким именно мог бы стать такой новый язык, надо пройти еще очень долгий путь, двигаясь небольшими шагами. Необходим способ расширения языка путем добавления новых возможностей, которые сводились бы к базовым возможностям и объяснялись в их терминах, а не подменяли их.
Такой способ организации языков хорошо известен из технологий традиционного программирования. Это – аппарат производных типов данных. В современных объектно-ориентированных языках этот аппарат весьма развит, но нам более полезна аналогия со старыми языками, такими, как C (без ++) или Паскаль.
В этих языках программист, которому не хватает предопределенных в языке, базовых типов, может придумать, описать и использовать собственный, производный тип. Обычно таким типом является структура, а использование в программе переменных производного типа записывается в виде обращений к полям структуры, тип которых может быть уже базовым. В Автокоде, как мы знаем из Части 1 Руководства, похожим способом записывается работа с батареями блоков памяти, причем роль «полей структуры» играют регистры доступа к батарее.
Вводя в Автокод производные типы данных, мы решили использовать эту же форму записи. Переменная производного типа, подобно батарее блоков памяти, представляет собой «структуру», снабженную конкретным набором «полей» - регистров доступа. Объявление такой переменной записывается иначе, чем объявление батареи блоков памяти (см. ниже), но работа с ней в тексте схемы записывается тем же способом, напоминающим обращение к полям структуры в традиционных языках программирования.
Например, если переменная f имеет такой производный тип, что среди регистров доступа есть выходной регистр готовности с именем rdy и выходной регистр данных с именем out, то считывание выходных данных при наличии готовности записывается примерно так:
if ( f.rdy == 1 )
b = f.out
endif
Аналогия между структурами традиционных языков программирования, батареями блоков памяти и переменными производных типов в Автокоде на этом заканчивается. Между этими тремя сходными по форме записи понятиями есть большие различия по существу. Внимательное рассмотрение этих различий – хороший способ понять, что же именно представляют собой производные типы данных в Автокоде.
Структура (производный тип) в традиционном языке программирования – это форма организации памяти. Присвоив значение некоторому полю структуры, Вы сделали именно и только это – сохранили некоторое значение под некоторым именем. Конкретный формат структуры, то есть список имен и типов ее полей, задается программистом. Программист волен придумать структуру с любым набором полей, а затем объявлять переменные такого типа.
Батарея блоков памяти, как и переменная производного типа в Автокоде – это функциональное устройство. Присвоив значение некоторому регистру доступа, Вы выполнили некоторое действие с этим устройством, почти всегда далеко выходящее за пределы просто запоминания значения под указанным именем, или даже не имеющее с таким запоминанием ничего общего. Как батарея блоков памяти, так и каждый из предусмотренных в Автокоде производных типов характеризуется совершенно конкретным набором имен и типов «полей структуры» - регистров доступа. В настоящей версии Автокода пользователь имеет лишь очень ограниченные возможности создания собственных производных типов (см. ниже). Обычно разрешается только численно параметризировать готовые шаблоны, подобно заданию характерных размеров при объявлении батареи блоков памяти.
Наконец, между батареей блоков памяти и переменной производного типа в Автокоде есть существенная разница.
Батарея блоков памяти – черный ящик, первичное неопределяемое понятие. Ее работа не выразима в терминах каких-либо «более базовых» понятий и конструкций языка. Потому этот тип и называется базовым, наряду с типом «регистр».
Переменная производного типа и работа с ней, напротив, всегда сводятся к понятиям базового подмножества Автокода. Все, что записывается при помощи переменных какого-либо производного типа, можно в принципе записать и без них, причем схема после трансляции получится такая же. Объяснение смысла конкретных производных типов всегда проводится в терминах понятий базовых возможностей языка. Здесь уместна еще одна аналогия с традиционными языками программирования. Наличие в традиционном языке оператора цикла не является, строго говоря, базовым понятием. Использование операторов цикла в программе приветствуется по целому ряду соображений, но в принципе любой цикл можно записать при помощи условных переходов. Условный переход же, в свою очередь – понятие базовое, ни к чему «более базовому» не сводимое.
Полезно понимать, что транслятор Автокода действительно сводит, в буквальном смысле этого слова, работу с производными типами к базовым возможностям языка. Выполняя предварительный проход по тексту схемы, он «разворачивает» все конструкции, связанные с производными типами, чтобы получить текст на базовом подмножестве Автокода. Для некоторых производных типов, таких, как, например, «вычислитель арифметического выражения с плавающей точкой», это «сведение» представляет собой довольно сложную работу, затрагивающую многие разделы схемы. Впрочем, эту работу транслятор целиком берет на себя.
Переходим к описанию предусмотренных в настоящей версии Автокода производных типов.
Общий синтаксис объявления переменной производного типа будет представлен ниже. Сначала перечислим имеющиеся на сегодня производные типы и охарактеризуем кратко их назначение.
- mainprogram32 – семейство производных типов интерфейса схемы с управляющей программой. Переменные такого типа позволяют автоматизировать прием данных в схему из управляющей программы и выдачу результатов в обратном направлении;
- floatexpr – семейство производных типов вычислителей арифметических выражений с плавающей точкой одинарной точности. Переменные такого типа позволяют автоматизировать арифметические вычисления, заданные формулой, выполняемые над данными в векторно-конвейерном режиме;
- waits – производный тип линий задержки. Переменные этого типа применяются для выравнивания ритма подачи данных на вычислительный конвейер в тех случаях, когда полностью автоматически рассчитать этот ритм не удается (см. ниже);
- cross – производный тип пятиточечного разностного шаблона. Специализированная, проблемно – ориентированная языковая конструкция.
Ниже приводятся более подробные пояснения.
Семейство производных типов mainprogram предоставляет программисту возможность записать одновременно стандартный заголовок схемы и стандартную часть логики обмена данными между схемой и управляющей программой в виде всего четырех строк. Эти строки заменяют стандартный заголовок (записываются в начале схемы с нулевой позиции строки, до них могут быть только директивы define) и имеют вид:
mainprogram32
<объявление переменной производного типа Memory>
<объявление переменной производного типа Register>
return <список возвращаемых значений>
например:
mainprogram32
Memory::(ram arr1(320), ram arr2(320)) 32 prt(8)
Register::(param1,param2,param3) 32 par
return par(result), prt(arr1, arr5)
Смысл приведенного здесь заголовка схемы следующий.
Схема принимает от управляющей программы два массива, размещая их в батареях блоков памяти arr1 и arr2, соответственно. Каждая из батарей имеет длину 320 элементов. Общая для обеих батарей ширина элемента – 32 бита (и всегда должно быть 32), ширина батареи – 8 элементов. Прием происходит через интерфейсные регистры доступа к диапазону памяти (ADDR, DI, WE). Имя «ответственной» за этот прием переменной типа «Memory» - prt (имена всех переменных производного типа задаются пользователем). Со стороны программы используется специальная стандартная функция, вызывающая внутри себя to_coprocessor().
Также схема принимает от управляющей программы три одиночных значения, помещая их в регистры param1, param2
и param3, соответственно. Общая ширина значений – 32 бита (и всегда должно быть 32). Имя «ответственной» за этот прием переменной типа «Register» - par. Со стороны программы используется специальная стандартная функция, вызывающая внутри себя to_register() и реализующая, совместно со схемой, несложный протокол передачи любого числа одиночных значений через два интерфейсных регистра, предусмотренных в базовом стандартном заголовке.
По окончании работы схемы следует вернуть управляющей программе значение регистра result (должно быть не более одного) и массивы значений двух батарей, arr1 и arr5, ширина которых должна быть равна 8. Программа примет возвращаемые значения, обратившись к специальным стандартным функциям. Схема сигнализирует программе о завершении своей работы и готовности к выдаче возвращаемых значений, когда в разделе действий на каждом такте или в разделе последовательных действий схемы выполнится специальный оператор return. В разделе действий на каждом такте он записывается просто:
return
в разделе же последовательных действий может сопровождаться меткой, на которую следует перейти после возврата результирующих значений, например:
return loop5
Регистры и батареи блоков памяти, упомянутые в объявлениях Memory и Register, объявлять в схеме не надо (транслятор сделает это сам). Возвращаемые (из списка
return) значения объявлять надо, если они не входят в списки Memory и Register.
Теперь рассмотрим использование упомянутых здесь языковых конструкций более подробно.
Переменные типа Memory.
На использование в схеме батарей блоков памяти, перечисленных в объявлении переменной типа Memory (в нашем примере – arr1, arr2 и arr5), накладывается ограничение. Присваивания значений регистрам dina, addra, wea такой батареи возможны только в комбинационной части схемы.
Объявление переменной типа Memory должно присутствовать в заголовке, даже если в схему перед началом работы не передается ни один массив. В этом случае скобки с перечислением входных батарей блоков памяти следует оставить пустыми:
Memory::() 32 prt
Со стороны управляющей программы передача в схему массивов осуществляется обращением к функции:
void logic_put(int number, int addr, void *arr, int len)
- записать массив arr длиной
len 32-разрядных слов во входную батарею блоков памяти сопроцессора, имеющую порядковый номер number, со смещением addr; измеренным в 32-разрядных словах.
В нашем примере входных батарей две: arr1 (с порядковым номером 0), и arr2 (с порядковым номером 1).
Передача данных в обратном направлении осуществляется обращением к функции:
void logic_get(int number, int addr, void *arr, int len)
– прочитать данные из батареи блоков памяти сопроцессора, имеющей порядковый номер number, со смещением addr, длиной len, в массив arr;
Под порядковым номером здесь понимается номер в соответствующем списке последней строки заголовка: arr1 имеет номер 0, arr5 – номер 1.
Описанные функции не имеют никаких встроенных средств синхронизации. Технически вполне возможно, например, вызвать logic_put() во время работы схемы, связанной с доступом изнутри схемы к arr1 и/или arr2, и ни к чему хорошему для схемы это не приведет. В этой связи вполне естественно озадачиться вопросом о том, в какие моменты времени можно вызывать эти функции, например, как понять, что уже пора выполнять logic_get(). Вся синхронизация взаимодействия программы со схемой происходит во время передачи одиночных параметров, к рассмотрению которой мы и переходим.
Переменные типа Register.
Объявление переменной типа Register должно присутствовать в заголовке, даже если в схему перед началом работы не передается ни один одиночный параметр. В этом случае скобки с перечислением входных одиночных параметров следует оставить пустыми:
Register::() 32 par
В отличие от рассмотренного выше обмена данными между массивами программы и батареями блоков памяти схемы, передача одиночных параметров предусматривает обязательную синхронизацию. Предполагается схема синхронизации, уже знакомая нам по примерам из Части 1:
- схема начинает работу с ожидания передачи некоторого одиночного параметра,
- программа, в свою очередь, сначала передает данные во все входные массивы, затем – во все одиночные параметры, причем в последнюю очередь – в тот, прием которого является для схемы сигналом начать работу.
Для реализации этой дисциплины в переменной типа Register предусмотрен доступный на чтение регистр доступа rdy, который становится равным единице на такте поступления соответствующего входного параметра.
Например:
if(par.rdy(param1)) // Если принят параметр param1,
... // делать одно,
else
... // иначе – другое.
endif
Или:
if(!par.rdy(param2)) // Пока не принят параметр param2,
... // делать одно,
else
... // иначе – другое.
endif
В случае, если в схему не передается ни один параметр, момент окончания передачи в схему всех массивов можно определить по равенству единице регистра par.rdy().
На стороне программы для передачи в схему произвольного подмножества одиночных параметров следует вызвать стандартную функцию logic_init(). Это функция с переменным числом аргументов, первым ее аргументом является количество передаваемых в схему параметров, а далее идут пары аргументов: порядковый номер аргумента и его значение. В результате этого вызова каждый из перечисленных в списке аргументов параметр будет передан, после чего соответствующий ему регистр par.rdy() станет равным 1. Передачу не всех, а только части параметров можно использовать при повторном запуске схемы для обработки новой порции исходных данных, с сохранением некоторых однократно выполненных начальных настроек.
Как уже говорилось выше, синхронизация выдачи результатов работы из схемы в программу осуществляется со стороны схемы выполнением оператора return.
Для приема программой выданного из схемы возвращаемого параметра следует обратиться к стандартной функции void logic_wait(int tim, int *val)
Первый аргумент этой функции – значение времени ожидания в секундах. При отладке схемы часто возникает необходимость «подождать очень долго и узнать, чему равен параметр, который схема почему-то не возвращает». Чтобы сделать это, надо задать значение tim отличным от нуля. В окончательном варианте программы, для взаимодействия с уже отлаженной схемой, значение tim задается равным 0. В этом случае считывание значения возвращаемого параметра происходит после того, как схема сигнализирует о завершении работы, выполнив оператор return.
Замечание о нумерации входных параметров и массивов.
Для функции logic_put порядковый номер number должен соответствовать позиционному номеру блока памяти в списке параметров, заданном при объявлении объекта Memory (нумерация начинается с 0). Например, в схеме объявлено:
Memory::(ram arr1(320), ram arr2(320)) 32 prt(8)
Запись данных в ram arr1 в программе:
logic_put( 0, 0, my_massiv1, 320)
Для функции logic_get порядковый номер number должен соответствовать позиционному номеру блока памяти в списке возвращаемых массивов (нумерация начинается с 0). Например, в схеме объявлено:
return prt(arr1, arr5)
Считывание данных из arr5 в программе:
logic_get( 1, 0, my_massiv5, 320)
Для функции logic_init порядковый номер должен соответствовать позиционному номеру регистра в списке параметров, заданном при объявлении объекта Register (нумерация начинается с 1). Например, в схеме объявлено:
Register::(param1,param2, param3) 32 par
Запись параметров в схему в программе:
logic_init(3, 1, arg1, 2, arg2, 3, arg3)
Если в ходе выполнения программы необходимо запустить схему повторно, изменив значение, допустим, только arg3, то вызов функции будет таким:
logic_init(1, 3, arg3)
Если для работы схемы (или для ее повторного запуска) параметры не нужны, то вызов функции будет таким:
logic_init()
Семейство производных типов floatexpr
Предоставляет программисту возможность автоматизировать построение части схемы, вычисляющей значение арифметико-логического выражения с плавающей точкой в векторно-конвейерном режиме.
Для создания переменной, реализующей вычисление конкретного выражения, ее надо объявить в разделе объявления переменных схемы, как имеющую тип floatexpr. В скобках указывается выражение, которое надо вычислить. Операнды выражения должны иметь вид in<число>, напр. in1, in2 и т.д.
Например:
floatexpr::((in1+in2)*sqrt(in3)-(in4/in1)) 32 my_expr(2)
Здесь объявлена векторная, шириной 2 переменная с именем my_expr, одинарной точности (ширина 32 бита, другие варианты ширины пока не реализованы).
Регистрами доступа к этой переменной являются:
- регистры входных данных - .in1, .in2, …, .inN (32 разряда) - для записи
- регистр результата - .out (32 разряда) - для чтения
- входной регистр разрешения - .we (1 разряд) - для записи
- выходной регистр готовности - .rdy (1 разряд) - для чтения
Будучи векторной, шириной 2, такая переменная порождает два параллельно работающих вычислителя по указанной формуле, каждый из которых работает в конвейерном режиме. Новые наборы значений входов подаются на каждом такте, результаты поступают на выход также на каждом такте, но с задержкой на несколько тактов, величина которой зависит от сложности выражения.
Теоретически конкретную величину задержки можно узнать из диагностических сообщений транслятора и учесть в схеме, но это плохой стиль программирования, усложняющий отладку и модификацию схемы. Правильнее использовать для синхронизации объемлющей схемы с вычислительным конвейером регистры разрешения и готовности. Принцип их работы разъясним в два этапа. Сначала дадим «наивное» объяснение, затем – более правильное.
«Наивное» объяснение состоит в том, что вычислитель выражения принимает очередной набор исходных данных только на тех тактах, на которых регистр разрешения равен 1. Отсюда его название – установка этого регистра в единицу разрешает вычислителю принять очередные исходные данные. На том такте, на котором результат этого «разрешенного» вычисления «созреет», регистр готовности станет равным единице. Таким образом, если Вам необходимо вычислить по заданной формуле 5 раз, подайте последовательно 5 разрешений на вход, и зафиксируйте 5 сопровождаемых готовностью результатов на выходе.
Приведенная в предыдущем абзаце модель поведения вычислителя не противоречит действительности, но является не вполне точной. В действительности вычислитель приступает к вычислению с очередным набором исходных данных на каждом такте, независимо от того, разрешен их прием или нет. Сигнал разрешения лишь «метит» те наборы исходных данных, при готовности результата вычислений с которыми автор схемы хотел бы получить единицу в выходном регистре готовности. Например, если автор схемы все же решится не использовать регистров разрешения и готовности, а работать по фиксированным задержкам, все вычисления будут происходить правильно, даже если регистр разрешения будет всегда равен 0. Понимание уточненной модели может оказаться очень полезным при отладке схемы.
В зависимости от характера вычислений или формы записи, можно выделить 4 вида объектов типа floatexpr:
- простые арифметические выражения;
- агрегатные вычисления (ключевые слова SUM, MAX, MIN);
- логические выражения (ключевое слово logic);
- сложные арифметические выражения (ключевое слово multi).
Простое арифметическое выражение.
Простое арифметическое выражение – это реализация поэлементного вычисления формулы для нескольких потоков входных данных, в результате вычисления которой образуется один поток выходных данных. Для примера рассмотрим реализацию формулы (a + b)*sqrt(c) – (d/a). Входными потоками для нее будут элементы 4-х массивов, расположенные в блоках векторной памяти с шириной батареи, например, 2. Приведенный выше пример как раз иллюстрирует простое арифметическое выражение:
floatexpr::((in1+in2)*sqrt(in3)-(in4/in1)) 32 my_expr(2)
В исполнительной части схемы будут доступны следующие регистры доступа к переменной
my_expr:
my_expr.in1(2) … my_expr.in4(2), my_expr.we(2), my_expr.out(2), my_expr.rdy(2).
Как видим, все регистры доступа – векторные, шириной 2.
Агрегатные вычисления.
Вычисление агрегатных функций (SUM, MAX, MIN) – это получение скалярного результата в ходе вычисления (суммировании, выборе максимального или минимального числа) всех элементов массива. Для примера рассмотрим реализацию функции SUM (для MAX и MIN все рассуждения будут аналогичны). Допустим, необходимо подсчитать сумму всех элементов массива, расположенного в батарее блоков памяти шириной 8.
Форма записи в разделе объявления переменных будет следующая:
floatexpr::SUM(in1, 8) 32 my_sum
где in1 – поток данных, а 8 – количество потоков.
В исполнительной части схемы будут доступны следующие регистры доступа к переменной my_sum:
my_sum.in1(8), my_ sum.out, my_ sum.we, my_ sum.rdy.
В данном случае только входной регистр данных будет векторным, остальные регистры скалярные.
Потоком может быть любое арифметическое выражение, в этом случае оно должно быть заключено в круглые скобки:
floatexpr::SUM(((in1-in2)*in3), 8) 32 my_sum1
Входных векторных регистров для этого примера будет 3.
Можно переменную для агрегатного суммирования объявить векторной, но тогда количество суммируемых потоков должно быть равным 1:
floatexpr::SUM((in1*in2), 1) 32 my_sum2(8)
В этом случае все регистры объекта my_sum2 будут векторными.
Логическое выражение.
Логическое выражение – это реализация вычисления истинности логического выражения для 2-х и более входных потоков. Выходной поток – результат вычисления истинности логического выражения (1 - выражение истинно). Ширина выходного потока – 1 разряд. Для примера рассмотрим реализацию логического выражения:
(a < b) && (c >= d). Входными потоками для нее будут элементы 4-х массивов, расположенные в батареях блоков векторной памяти шириной 1.
Форма записи в разделе объявления переменных будет следующая:
floatexpr::logic((in1 < in2) && (in3 >= in4)) 32 my_if
В исполнительной части схемы будут доступны следующие регистры доступа к переменной my_if:
my_if.in1 … my_if.in4, my_if.true, my_if.we, my_if.rdy.
Все регистры доступа к переменной
my_if будут скалярными, ширина выходного регистра результатов my_if.true - 1 разряд.
Сложное арифметическое выражение.
Сложными называются арифметические выражения, в которых, помимо арифметических операций, имеются обращения к другим выражениям.
Для примера рассмотрим реализацию формулы: (a +b) * c – (d / (f - (a + b) * c)).
Входными потоками для нее будут элементы пяти массивов, расположенные в блоках векторной памяти с шириной батареи 1.
Можно заметить, что выражение (a +b) * c в формуле встречается дважды. Если объявить вычисление как простое, то данное выражение в схеме оформится в виде двух пар вычислителей с одинаковыми данными на входе, и в двух местах схема будет вычислять одно и то же, расходуя лишнее оборудование. Логичнее выделить это выражение, описав его отдельно, а результат подставить в основное выражение:
floatexpr::multi(!part((in1+in2)*in3);(part-(in4/(in5-part)))) 32 ne_simple
Перечень простых выражений пишется через точку с запятой. Каждое из них должно быть заключено в круглые скобки, перед которыми ставится идентификатор. Только одно из выражений может быть основным (его идентификатор должен совпадать с именем объекта, либо вообще отсутствовать).
В исполнительной части схемы будут доступны следующие элементы объекта ne_simple:
ne_simple.in1 … ne_simple.in5, ne_simple.out, ne_simple.we, ne_simple.rdy.
Все регистры объекта ne_simple будут скалярными.
Знак
! перед идентификатором part означает, что выражение part не будет использоваться как самостоятельное. Знание этого позволяет транслятору не генерировать для part имена регистров доступа. В противном случае для нашего примера к вышеперечисленным элементам объекта ne_simple добавились бы следующие:
входной скалярный регистр разрешения – part.we
выходной скалярный регистр данных – part.out
выходной скалярный регистр готовности – part.rdy
Кроме обращений к более простым выражениям, сложное выражение может содержать условную логику, которая записывается, как условное выражение в языке C, то есть в виде:
(a <арифметическая_операция> {(<условие>) ? b : c})
, где
- a, b и c – это либо переменная, либо идентификатор, либо выражение (выражение должно быть заключено в круглые скобки);
- <условие> - простое логическое выражение с участием переменной, определенной в схеме (ее объявление должно предшествовать объявлению объекта). Условное выражение не должно быть вложенным (т.е. выбор возможен только из двух вариантов). Фигурные скобки, ограничивающие условное выражение, обязательны.
Например:
floatexpr::multi(!add(in1+in2);mul(add*in3*in4);(in5-{(my_var == 0) ? add : mul})) sub
Запись условного выражения означает следующее: если переменная my_var равна 0, то из in5 вычитается выражение add, иначе – mul.
В исполнительной части схемы будут доступны следующие элементы объекта sub:
sub.in1 … sub.in5, sub.out, sub.we, sub.rdy, mul.out, mul.we, mul.rdy.
В заключение обзора объектов floatexpr приведем некоторые сведения общего характера.
Для арифметических выражений, кроме основных 4-х операций ( +, -, *, / ), определены следующие операции:
sqrt(a) | – извлечение квадратного корня из a; |
pow(a, n) | – возведение a в целочисленную степень n; |
max(a, b) , min(a, b) | – выбор наибольшего (наименьшего) из a и
b |
где a и b – либо переменная, либо арифметическое выражение.
Для pow, max и min выражение должно быть заключено в круглые скобки.
Определена еще одна операция: +- (плюс|минус). При подаче 1 на вход регистра (поле структуры - .operation) выполняется операция вычитания, а при 0 – операция сложения (используется при больших вычислениях, когда приходится экономить вычислительные элементы). При использовании этой операции, для объекта (напр. name) транслятором создается еще один элемент:
входной регистр управления операцией – name.operation (ширина – 1 разряд).
Еще раз отметим, что все вычислительное выражение необходимо заключать в круглые скобки. Внутри выражения скобками можно и нужно выстраивать порядок выполнения вычислений (не только для правильности вычислений, но и для создания более сбалансированной схемы компонента).
После построения компонента транслятор может выдать на экран два вида сообщений. Смысл этих сообщений поясним на конкретном примере. Для построения компонента, реализующего формулу: (a + b)*c + d/f, создадим объект:
floatexpr::((in1+in2)*in3 - in4/in5) 32 expr
В ходе трансляции будет выдано 2 сообщения:
Warning! Input 'expr.in3' should be delayed on 5 ticks
Component 'expr': all delay 33(real 33) ticks
Разберем их по порядку. Первое сообщение предупреждает о том, что данные, поступающие на вход in3, должны быть задержаны относительно всех остальных входов на 5 тактов (данным in3 придется дождаться выполнения операции сложения in1 и in2). Транслятор не ставит автоматически линий задержки на входные данные, т.к. во многих случаях без них можно обойтись. Например, если на вход подается константа, само понятие задержки не имеет смысла, а если на вход подается массив, его задержку разумно выполнить, начав отсчет индекса не с нуля, а с отрицательного значения. Если же без задержки в явном виде не обойтись, то ее можно поставить вручную, тогда транслятор сообщает программисту, какова должна быть ее величина.
Вторая строка – сообщение о суммарной задержке компонента: общая задержка компонента expr – 33 такта (реально установленная – 33 такта). Что означает реально установленная задержка? В нашем примере первый результат вычисления появится через 33 такта после появления 1 на регистре разрешения expr.we, следовательно, и сигнал готовности появится через 33 такта (считается по наиболее длинной по числу тактов ветке: задержка на деление 28 тактов плюс задержка на последующее вычитание еще 5 тактов). В ряде случаев, для синхронизации последующих вычислений, необходимо иметь выходной сигнал готовности не от последней операции, а от промежуточной. Готовность можно «передвинуть», поставив возле необходимой операции знак ^. В этом случае выходной сигнал готовности компонента установится в 1 после появления первого результата именно этой операции – это и будет реально установленная задержка. В нашем примере реально установленная задержка совпадает с общей.
Сделав объявление в виде:
floatexpr::((in1+in2)*in3 - in4^/in5) 32 expr
мы получили бы сообщение:
Component 'expr': all delay 33(real 28) ticks
Использование производного типа floatexpr существенно упрощает описание схемы и избавляет от необходимости самостоятельно строить вычислительные компоненты. Его выгодно использовать даже в тех случаях, когда для схемы нужен простой вычислитель (из набора библиотечных компонентов), т.к. транслятор избавит программиста от необходимости записи подключения компонента к регистрам основной схемы (оператор insert) и декларации этих регистров.
Производные типа cross.
При решении ряда задач для вычислений требуется выборка данных из массива в соответствии с пятиточечным разностным шаблоном, что предполагает работу со строками и столбцами двумерного массива. Представление же массива в схеме является одномерным (точнее, его «двухмерность» - векторная, определяемая шириной батареи векторной памяти). Транслятор создает часть схемы, которая формирует строки массива в изначальном виде и осуществляет необходимую выборку из них.
В разделе деклараций объявляется объект типа cross, в качестве аргументов ему передается список параметров, необходимый для реализации этой задачи:
cross::( W, length, my_we ) 32 buffer(L)
где
L | — ширина батареи памяти, в которой располагается исходный массив; |
W | — числовая константа, не меньшая, чем NCols*2 (NCols - длина строки исходного массива); |
length | — переменная или числовая константа, равные NCols/L; |
my_we | — сигнал разрешения вычислителя, на который будут подаваться выходные данные объекта (необязательный аргумент). |
В исполнительной части схемы будут доступны следующие элементы объекта buffer:
buffer.in(L) | — входной регистр данных; |
buffer.we (L) | — входной регистр разрешения; |
и 5 выходных регистров данных, соответствующих пятиточечному шаблону:
buffer.mid(L) | — [i][j] элемент массива; |
buffer.up(L) | — [i-1][j] элемент массива; |
buffer.down(L) | — [i+1][j] элемент массива; |
buffer.left(L) | — [i][j-1] элемент массива; |
buffer.right(L) | — [i][j+1] элемент массива; |
где i и j – текущие номера строки и столбца исходного массива,
buffer.rdy(L) – выходной регистр готовности (создается, если в числе аргументов не будет указан управляющий сигнал разрешения для последующего вычисления),
buffer.before_rdy – выходной сигнал готовности, на 1 такт опережающий сигнал buffer.rdy (необходим для организации синхронного чтения других массивов).
Все элементы объекта buffer будут объявлены транслятором.
Все переменные из списка аргументов должны быть объявлены самостоятельно (исключая тех, которые объявлены в других объектах).
В комбинационной части схемы на входы buffer.in подаются выходы соответствующего блока памяти, а выходные регистры объекта buffer – на входы нужного вычислителя. Если регистр разрешения вычислителя не указан в качестве аргумента объекта buffer, то на его вход подается регистр готовности buffer.rdy.
В разделе последовательных действий необходимо организовать цикл считывания из памяти, и на весь период перебора адресов установить в 1 сигнал разрешения buffer.we.
Во второй части настоящего Руководства было подробно изложено, как организовать цикл чтения из памяти для непрерывной подачи данных на вычислитель, и как синхронизировать начальный управляющий сигнал с этим потоком. Если между памятью и вычислителем необходим преобразователь типа cross, то при организации цикла считывания следует учитывать следующее. Первое: управляющий сигнал в конце цикла считывания обязательно надо сбрасывать в 0, даже, если число таких циклов > 1. Второе: условием выхода из цикла должно быть значение счетчика адресов, равное последнему адресу.
К примеру, имеется исходный двумерный массив размером 32 * 20. В схеме, допустим, он будет располагаться в векторной памяти с blocksize = 4 (ширина вектора). Здесь следует отметить, что работа с двумерными массивами предполагает, что длина строки исходного массива должна быть кратна ширине векторной памяти. В ram'е одна строка займет 8 адресов (32 / 4), а весь массив - 20 * (32 / 4) = 160 адресов с 0 по 159. Цикл считывания будет выглядеть так:
{
addr_read = 0
}
loopN:
{
if(addr_read < 159)
next loopN
addr_read++
buffer.we = 1
else
buffer.we = 0
addr_read = 0
endif
}
После запуска цикла, через 16 тактов (число тактов равно числу адресов в ram'е, занимаемых двумя строками исходного массива) на вход вычислителя начнут поступать каждый такт по 5 чисел, соответствующих пятиточечному шаблону. Если в объявлении объекта cross в числе аргументов был указан управляющий сигнал для этого вычислителя, то он автоматически установится в 1 на весь период считывания. Если нет, то в комбинационной части схемы необходимо сделать запись:
expr1.we = buffer.rdy
Производные типа
waits.
Транслятор создает в схеме линии задержки (в виде компонента). В разделе деклараций объявляется объект типа waits, в качестве аргументов ему передаются имя регистра, который нужно задержать и список величин задержек в тактах:
waits::(my_reg, 10, 20, 25) 32 my_regdelay
Исходный регистр должен быть объявлен самостоятельно (кроме тех случаев, когда это элемент другого объекта). Если регистр векторный, то векторным объявляется и объект.
В исполнительной части схемы будут доступны следующие элементы:
my_regdelay.10, my_regdelay.20, my_regdelay.25.
Все выходные регистры объявляются транслятором.
На этом обзор новых возможностей языка Автокод HDL завершен.
Ниже на двух примерах будет продемонстрировано использование объектов, создаваемых транслятором, в реальных схемах. Но перед этим хотелось бы обратить внимание на следующее. Любая вычислительная схема изобилует множеством переменных (в десятки раз больше, чем в соответствующей ей классической программе). Транслятор значительную часть этих переменных формирует в виде полей соответствующих структур объекта (не все из них доступны программисту при написании схемы). Поскольку результатом трансляции схемы будет текст на языке VHDL, в котором для имен не предусмотрен разделитель «.», то при трансляции такой разделитель заменяется на знак «_». Поэтому, во избежание пересечения имен, не следует давать своим переменным имена, начинающиеся на <имя_объекта>_.
В качестве первого примера покажем, как можно было бы написать схему вычисления интеграла, подробно рассмотренную и записанную на базовом языке Автокод HDL во второй части Руководства.
Вызывающая программа:
#include <stdio.h>
#include "comm.h"
#define LARR 16384
#define L 8
int fpga_main( void )
{
static float arr[LARR];
int result, i, adr;
float count;
for (i = 0; i < LARR; i++)arr[i] = i+1;
count=LARR;
adr=(LARR/L) -1;
logic_put(0, 0, arr, LARR);
logic_init(2, 1, *((int *)(&count)), 2, adr);
logic_wait(0, &result);
printf("result:%f\n",*((float *)(&result)));
return 0;
}
Описание схемы:
define(L, 8)
mainprogram32
Memory::(ram arr(16384)) 32 prt(L)
Register::(count, last_addr) 32 par
return par(divide)
declare
floatexpr::(in1*in2) 32 mul
floatexpr::(in1/in2) 32 div
floatexpr::SUM(in1,L) 32 my_sum
reg 32 addr_read
reg 32 divide
enddeclare
arr.dina[0] = mul.out
arr.dina[decr(L)] = mul.out
arr.addra[0] = 0
arr.addra[decr(L)] = last_addr
arr.wea[0] = (addr_read == 8) ? mul.rdy : 0
arr.wea[decr(L)] = (addr_read == 7) ? mul.rdy : 0
mul.in1= (addr_read == 2) ? arr.douta[7] : arr.douta[0]
mul.in2 = float(0.5)
my_sum.in1 = arr.doutb
arr.addrb = addr_read
div.we = my_sum.rdy
div.in1 = my_sum.out
div.in2 = count
[
my_sum.we = 0
[]
if(div.rdy==1)
divide = div.out
return
endif
]
loop0:
{
if(!par.rdy(last_addr))
addr_read = 1
next loop0
endif
}
loop1:
{
my_sum.we=1
if(addr_read<last_addr)
addr_read++
next loop1
else
addr_read=0
endif
}
{
}
loop2:
{
my_sum.we=0
next loop0
}
Как видим, использование производных типов данных значительно сократило объем текста и упростило логику записи. Сохраним текст в файле под именем new_integral.avt и схема вычисления интеграла готова к трансляции:
avto new_integral.avt
В качестве второго примера рассмотрим схемную реализацию решения задачи Дирихле на равномерной прямоугольной сетке методом красно-черной релаксации.
Исходный текст вычислений на «С»:
void poissns( float *p1, float *rhs1, int nrows, int ncols, int niter,
float omega, float delx, float dely )
{
static float *p[1000], *rhs[1000];
int i, j;
float rdx2, rdy2, beta1, beta2;
for ( i = 0; i < nrows; i++ )
{
p[i] = p1; p1 += ncols;
rhs[i] = rhs1; rhs1 += ncols;
}
rdx2 = 1./delx/delx;
rdy2 = 1./dely/dely;
beta1 = 1.-omega;
beta2 = -omega/(2.0*(rdx2+rdy2));
while ( niter-- )
{
for ( i = 1; i < (nrows-1); i++ )
{
for ( j = (i%2)+1; j < (ncols-1); j+=2 )
{
p[i][j] = beta1*p[i][j] - beta2*(
(p[i+1][j]+p[i-1][j])*rdx2
+ (p[i][j+1]+p[i][j-1])*rdy2
- rhs[i][j]);
}
}
for ( i = 1; i < (nrows-1); i++ )
{
for ( j = ((i-1)%2)+1; j < (ncols-1); j+=2 )
{
p[i][j] = beta1*p[i][j] - beta2*(
(p[i+1][j]+p[i-1][j])*rdx2
+ (p[i][j+1]+p[i][j-1])*rdy2
- rhs[i][j]);
}
}
}
}
В схеме имеет смысл реализовать вычисления в цикле while, оставив на долю программы формирование коэффициентов. Для упрощения вычислений, преобразуем основную формулу:
p[i][j] = p[i][j]*beta1 + rhs[i][j]*beta2 + (p[i+1][j]+p[i-1][j])*bx2
+ (p[i][j+1]+p[i][j-1])*by2;
где
bx2 = -beta2*rdx2;
by2 = -beta2*rdy2;
Исходными данными для передачи в схему будут: два массива p и rhs (размер ncols*nrows), коэффициенты beta1, beta2, bx2, by2 и число итераций в цикле niter.
Кроме этого, для обработки массивов в схеме понадобится информация о длине массива и длине одной строки в перерасчете на единицу блока векторной памяти. Определим ширину батареи векторной памяти, к примеру, равной 4. Тогда длина строки (сколько адресов в блоке памяти занимает одна строка) len = ncols/4, а длина массива (адрес последнего числа в блоке памяти) last = nrows*len-1.
Результатом будет массив p.
Перепишем исходную функцию, которую будет вызывать внешняя программа:
void poissnh( float *p1, float *rhs1, int nrows, int ncols, int niter,
float omega, float delx, float dely )
{
#define VL 4
float rdx2, rdy2, beta1, beta2, bx2, by2;
int len, last, ans;
logic_put( 0, 0, p1, nrows*ncols );
logic_put( 1, 0, rhs1, nrows*ncols );
rdx2 = 1./delx/delx;
rdy2 = 1./dely/dely;
beta1 = 1.-omega;
beta2 = -omega/(2.0*(rdx2+rdy2));
bx2 = -beta2*rdx2;
by2 = -beta2*rdy2;
len = ncols/VL;
last = nrows*len-1;
logic_init( 7, 1, *((int *)(&bx2)), 2, *((int *)(&by2)), 3, *((int *)(&beta1)),
4, *((int *)(&beta1)), 5, len, 6, last, 7, niter );
logic_wait( 0,&ans );
logic_get( 0, 0, p1, nrows*ncols );
}
Что нам понадобится в схеме? 2 блока векторной памяти для массивов p и rhs с шириной батареи =4 и объемом не менее nrows*ncols (объем памяти должен быть указан в виде целочисленной константы, для примера возьмем 20*32=640).
Поскольку в вычислении применяется выборка данных из массива в соответствии с пятиточечным шаблоном, нам понадобится объект типа cross, а для вычисления формулы – объект типа floatexpr.
Метод красно-черной релаксации основан на вычислении элементов в шахматном порядке, при этом на каждой итерации выполняются 2 цикла: сначала вычисляются элементы, стоящие на «красных» клетках, а потом – на «черных». Поэтому вычислителей понадобится в два раза меньше, чем длина вектора памяти. Все входы вычислителя придется мультиплексировать (чтобы обеспечить подачу данных в шахматном порядке). Результаты вычислений записывать в память тоже придется выборочно, учитывая этот порядок. Обеспечивать порядок «до» и порядок «после» будут несколько переменных.
define(L,4)
mainprogram32
Memory::(ram p(640), ram rhs(640)) 32 prt(L)
Register::( c_B2X2,c_B2Y2,c_BETA1,c_BETA2, c_length,c_last_word,niter) 32 par
return prt(p)
declare
// задаем основную формулу для вычисления:
floatexpr::(((in1+in2)*in3+(in4+in5)*in6)+(in7*in8+in9*in10)) 32 crest(eval(L/2))
// определяем объект для формирования пятиточечного шаблона:
cross::(64,c_length,crest.we) 32 buf(L)
reg 32 addr_write // счетчик адресов для записи в память
reg 32 addr_read // счетчик адресов для считывания из памяти
reg 32 counter_before // определяем 4 переменные,
reg 32 counter_after // необходимые для формирования шахматного порядка
reg 1 red_before // при считывании (before)
reg 1 red_after // и при записи (after)
reg 1 subphase_before // установка начального «цвета» клетки
reg 1 blockwe(L) // блокировка записи в память границ массива и
enddeclare // не участвующих в вычислениях элементов
// в комбинационной части формируем конвейер:
// чтение данных из памяти (p и rhs), формирование данных по шаблону (объект buf),
// вычисления (объект crest) и запись результатов в память (p)
do @1=0, decr(L)
p.dina[@1] = crest.out[@1/2]
p.addra[@1] = addr_write
p.addrb[@1] = addr_read
p.wea[@1] = crest.rdy[0] && ~blockwe[@1]
buf.in[@1] = p.doutb[@1]
enddo
{ p.web, rhs.web} = 0
crest.in(3,6,8,10)={ c_B2X2, c_B2Y2, c_BETA1, c_BETA2}
do @1=0,eval((L/2)-1)
crest.in1[@1] = (red_before == 0) ? buf.up[2*@1] : buf.up[2*@1+1]
crest.in2[@1] = (red_before == 0) ? buf.down[2*@1] : buf.down[2*@1+1]
crest.in4[@1] = (red_before == 0) ? buf.left[2*@1] : buf.left[2*@1+1]
crest.in5[@1] = (red_before == 0) ? buf.right[2*@1] : buf.right[2*@1+1]
crest.in7[@1] = (red_before == 0) ? buf.mid[2*@1] : buf.mid[2*@1+1]
crest.in9[@1] = (red_before == 0) ? rhs.doutb[2*@1] : rhs.doutb[2*@1+1]
enddo
// определяем границы массива для блокировки записи:
blockwe[0] = (counter_after == 1) ? 1 : red_after
blockwe[decr(L)] = (counter_after == c_length) ? 1 : ~red_after
do @1 = 1, eval(L-2)
blockwe[@1] = (@1 == (@1/2)*2) ? red_after : ~red_after
enddo
[
buf.we = 0
addr_read = 0
[]
// формируем шахматный порядок для считывания
if(crest.we[0] ==0)
red_before = subphase_before
counter_before = 1
else
if(counter_before == c_length)
red_before = ~red_before
counter_before = 1
else
counter_before++
endif
endif
// для синхронизации с потоками данных из buf,
// организуем считывание из rhs по сигналу buf.before_rdy
if(buf.before_rdy==1)
rhs.addrb++
else
rhs.addrb= c_length
endif
// организуем запись результатов вычислений в память p
// по готовности объекта crest, и формируем шахматный порядок для записи
if(crest.rdy[0] == 1)
addr_write++
if(counter_after == c_length)
red_after = ~red_after
counter_after = 1
else
counter_after++
endif
else
addr_write = c_length
counter_after = 1
red_after = subphase_before
endif
]
// ожидание начала работы
loop0:
{
if(!par.rdy(niter))
next loop0
subphase_before = 0 // устанавливаем начальный «цвет» клетки
endif
}
// основной цикл чтения данных из памяти p: повторяется niter*2 раз
// внутри цикла держим в 1 разрешение записи в buf;
// по исчерпании адресов сбрасываем в 0 разрешение записи,
// устанавливаем начальный адрес считывания и
// меняем начальный «цвет» клетки на противоположный;
// после каждого 2-го цикла уменьшаем число итераций на 1;
// возвращаемся в начало цикла
loop1:
{
if(niter > 0)
next loop1
if(addr_read < c_last_word)
addr_read++
buf.we=1
else
addr_read = 0
buf.we=0
subphase_before = ~subphase_before
if(subphase_before == 1)
niter--
endif
endif
endif
}
// дожидаемся последней записи в память, оформляем окончание работы
// и возвращаемся в начальную фазу ожидания
loop2:
{
if(crest.rdy[0] == 1)
next loop2
else
return loop0
endif
}
Часть 1.
Часть 2.
Часть 3.
|