Иерархические математические структуры для QuatCore

Jul 15, 2023 05:00

Этот вопрос меня не покидает ещё со времён PhysUnitCalc: как правильно реализовать на компьютере "многоуровневую" математику. В PhysUnitCalc верхним уровнем было добавление единицы измерения к "абстрактному числу", а этим "абстрактным числом" могло быть действительное или комплексное.

Сейчас речь скорее о том, что аппаратно у меня будет 16-битный процессор, но в самых ответственных местах мне надо будет перейти на 32 бита (или сдаться и сделать плавающую точку), затем "поверх" этих чисел накатать кватернионы, а поверх кватернионов - дуальные числа, а там, возможно, ещё один "слой" дуальных чисел, чтобы автоматически брать производные. А где-то будут вектора, объединённые в "массив векторов", который тоже захочется рассмотреть как единое целое, а иногда - как матрицу.

Причём речь не только о простоте программирования (перестать ручками "перекладывать циферки по одной", и писать гораздо более крупными мазками), но и о компактности кода. Если пытаться сделать некое подобие C++ Templates, это после компиляции просто лютейший ужас, а мне бы всю математику уместить в 4 килобайта... Полезешь в ООП с полиморфизмом - тут же окажется, что на стеке и в переменных хранить эти объекты нельзя, только выделять память, в переменной и на стеке хранить указатели и потом вовремя всё это освобождать. Тоже не хочу - ни динамического выделения памяти, ни, тем более, сборки мусора!

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



Мой "идеал" для математических процедур
До сих пор ни строчки кода не написал на Хаскелле, но книжку по нему честно осилил от корки до корки. Далеко не всё понял, и не сказать, что очень уж воодушевился чистой функциональщиной, вообще без переменных как таковых, но мне жутко понравилась идея в качестве аргументов принимать ЧТО УГОДНО, УДОВЛЕТВОРЯЮЩЕЕ РЯДУ УСЛОВИЙ. Это нечто можно складывать друг с другом и умножать - хорошо, мне больше ничего не надо, сейчас посчитаю! И где-то рядышком идея "обёртывания" данных в другие.

Учитывая, что у меня пока что огроменный запас по быстродействию (раз в 50), а вот памяти мало, хотелось бы получить именно такое поведение даже В РАНТАЙМЕ. Написал процедуру перемножения матриц, и она с равным успехом перемножит хоть матрицы из 16-битных целых, хоть из 32-битных, хоть из комплексных, или из дуальных.

"Умные" адреса
Идея в том, что у нас имеются избыточные биты для представления адреса. В случае QuatCore, вся оперативная память должна составить 1 килобайт, для адресации по 16-битным словам хватает 9 бит, тогда как шина данных 16-битная, т.е 7 бит есть "про запас". В 32-разрядных и тем более 64-разрядных системах, как правило, тоже избыточности хватает.

В тех же ARMах, увидев прорву "незадействованных" адресов, придумали Bit Banding (не путать с Banging), где адреса приписываются индивидуальным битам хотя бы ввода-вывода, чтобы можно было не делать специализированных команд "установить 1 бит", или не выполнять последовательность "прочитать полный байт, через AND/OR выставить нужный нам бит - и записать байт назад".

Я тоже нечто подобное попробовал с байтовым режимом, и эксперимент в целом считаю удачным.

Но можно "закодировать" в старших битах адреса не просто режим доступа (по словам, по байтам, по битам), но и "тип данных", и в этом есть огромный смысл! Мне не хочется, каждый раз вызывая процедуру, передавать ей целую прорву информации о том, "с чем она имеет дело", хочу передать адрес, по которому лежат данные - И ВСЁ.

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

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

"Аппаратный полиморфизм"
Мне давно не нравилось, что перед вызовом процедуры надо ЗАРАНЕЕ передать в неё параметры, записать в какой-нибудь из регистров. Тем более, было понятно, как в QuatCore сделать вызов процедуры с аргументом В ТОЙ ЖЕ СТРОЧКЕ. Буквально, если мы называем процедуру QAdd, мы запишем

QAdd Quat1

(прибавить кватернион, лежащий по адресу Quat1)

Для этого нужно зарезервировать десяток-другой адресов DestAddr под эти процедуры, т.е их адреса будут уже лежать в таблице, подготовленной компилятором, реализованной комбинаторной логикой, так я уже делал. Из нового: нужно два дополнительных регистра, LR (Link Register) и AR (Argument Register). При исполнении команды QAdd (и подобных ей) одновременно защёлкивается значение из шины данных (т.е адрес Quat1) в регистр AR, в это время текущее значение Program Counter (PC), т.е АДРЕС ВОЗВРАТА, записывается в LR, а в PC записывается значение из таблицы, т.е происходит прыжок в эту процедуру.

Может, вместо LR удастся всё-таки пустить "дополнительную шину" в модуль QuatCoreMem, чтобы он выполнил [SP++] (т.е PUSH), но только не значения с шины данных, а конкретно PC, Program Counter. Хотя хрен редьки не слаще, что регистр, что мультиплексор, одинаково примерно. Это буду смотреть по объёму, что у меня быстрее закончится, память или логические элементы...

Ну так вот, с учётом "умных адресов" можно данный подход ещё чуть улучшить: адрес процедуры определяется не только кодом команды (т.е DestAddr), но и старшими битами из шины данных. В коде оно может смотреться как-то так:

PLUS Quat PTR Quat1

(или, ещё лучше, саму метку Quat1 определить не через dw или db, а как раз как структуру Quat, после чего при её появлении в коде нужный тип в старших битах адреса будет выставлен компилятором автоматически)

Собственно, в ассемблере x86 уже приходилось временами адрес "уточнять" подобными директивами, только там это происходило на этапе компиляции - подбирался подходящий опкод, который обработает требуемый размер операнда. А здесь "уточнение" произойдёт непосредственно при выполнении - будет НЕСКОЛЬКО разных процедур PLUS, но исходя из типа аргумента, будет вызван НУЖНЫЙ НАМ.

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

Два стека
Всё бы хорошо, но довольно быстро упираешься в управление памятью. Был, конечно, вариант вообще всегда указывать "ручками", куда положить результат, вплоть до указания 3 адресов (первый операнд, второй операнд, результат). Но это громоздко получается, для QuatCore как-то естественнее каждый раз указывать по одному операнду, а тогда для каждого типа данных естественно было бы иметь свой "аккумулятор". Первым делом загружаешь в него первый операнд, потом вызываешь команду, например, "сложение", указывая второй, а потом после ряда операций куда-нибудь запихиваешь результат.

Поначалу я думал просто под каждый тип данных "зарезервировать" аккумулятор, условно говоря, статическую переменную. Но при таком подходе один тип данных не может "абстрагироваться" от того, из чего он составлен. Скажем, мы объявили "абстрактный" кватернион - четыре КАКИХ-ТО числа (то ли 16-битные, то ли 32-битные, то ли дуальные, кто их разберёт), но когда мы попросим ЗАГРУЗИТЬ кватернион, этот тип данных не сможет скомандовать для каждой компоненты "загрузись, кто ты там есть". Потому как у 32-битного типа данных будет, к примеру, свой аккумулятор, в 32 бита. Попросим для первой компоненты загрузить - всё хорошо. Попросим для второй - а аккумулятор-то у них "один-единственный", то есть первое затрётся тут же. Так что тут либо уже кватернион должен сосчитать условный SizeOf() всех своих компонентов, и хранить результат У СЕБЯ. Возникает взаимная зависимость: теперь и нижележащий компонент (например, 32-битное число, которое мы задействовали для кватерниона) должен знать, КУДА ему запихиваться, в общем, фигня какая-то, не складывается каменный цветок.

Выход пока вижу в использовании стека данных, на манер Forth. Поскольку он не будет перемежаться адресами возврата и сохранёнными копиями регистров, он сможет работать "связующим звеном" между арифметикой разных уровней.

Грубо говоря, я приказываю дуальному кватерниону: "запихивайся в стек". А тот и не думает ручками что-либо делать, там верхний уровень - это дуальное число, оно говорит: "дуальный компонент, запихивайся!" Тот оказывается обычным кватернионом, и говорит "k, запихивайся!". Компонент при k оказывается 32-битным числом и уже честно занимает свои 2 ячейки в стеке. За ним идёт j дуального компонента, затем i, скаляр, и, наконец управление возвращается к дуальному числу и оно говорит "основной компонент, полезай!". И таким же макаром запихивается первый кватернион.

Похожее происходит при извлечении из стека, и при арифметических операциях.

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

Как это примерно будет выглядеть

Дуальное комплексное число, составленное из обычных 16-битных чисел, его представление в памяти:

.data
DC1 dw Complex PTR Prime
Prime dw Int16 PTR RePrime
Dual dw Int16 PTR ReDual
RePrime dw 10
ImPrime dw 20
ReDual dw 30
ImDual dw 40

"Заголовок" этого числа: DC1. Давая этот адрес, будем назначать тип Dual. Непосредственно по этому адресу лежит адрес на первую, ОСНОВНУЮ компоненту, Prime. При этом адресу назначен тип, Complex. Соответственно, Prime содержит адрес непосредственно чисел, и этому адресу назначен тип Int16.

Разумеется, будут директивы для создания структур данных, которые всё это сделают автоматически, после того, как мы их опишем во включаемых файлах. Тогда оно будет выглядеть примерно так:

DC1 DUAL CreateDual(COMPLEX, CreateComplex(Int16, 10, 20), CreateComplex(Int16, 30, 40))

Работа с такими числами "на верхнем уровне" будет незамысловатой:

PUSH DC1
PLUS DC2
MUL DC3
POP Result

(положить в стек первое число, прибавить к нему второе, результат умножить на третье, и результат положить в память)

При этом PUSH, PLUS, MUL и POP - это всё полиморфные вызовы процедур.

Реализуется это примерно так. Описываем, как поместить в стек самое обычное 16-битное число:

Int16.PUSH proc
[DSP++] [AR]
JMP LR
Int16.PUSH endp

Здесь DSP - это Data Stack Pointer, т.е указатель на стек данных, AR - это Argument Register, LR - Link Register.

Тут важно, что при "полиморфном вызове" мы используем именно адрес числа, а в стек должны положить само число, поэтому ставим квадратные скобки. Поскольку Int16 - это "атомарный" тип, мы внутри Int16.PUSH знаем доподлинно, нам указали конкретное число, которое мы и загружаем.

Далее, опишем помещение в стек дуальных и комплексных чисел:

DUAL.PUSH:
COMPLEX.PUSH proc
[SP++] LR
[SP++] X
X [AR]
PUSH X+1
PUSH X
X [--SP]
JMP [--SP]
COMPLEX.PUSH endp

Поскольку и комплексные, и дуальные числа имеют идентичную структуру (два КАКИХ-ТО числа), мы не стали писать две разные процедуры. Чтобы обе процедуры "занимали одно и то же место", только одна оформлена как процедура, а другая просто как метка. Как мы помним, в ассемблере директивы proc/endp мало что делают, можно было бы и без них обойтись, на одних метках. Они больше для красоты...

Теперь есть весь необходимый код, чтобы исполнилась первая строчка "верхнего уровня", PUSH DC1. В адресе DC1 содержится тип, DUAL, поэтому вызывается конкретно DUAL.PUSH. Там мы Link Register заносим в стек (т.к при вызове из этой процедуры он затрётся), теперь именно в стеке сидит адрес возврата. Мы знаем, что по адресу DC1 у нас лежит ЕЩЁ ОДИН АДРЕС, на этот раз - основной части числа. Самое главное было здесь задать тип, COMPLEX, а если уж мы решили, что тип содержится в "умном адресе", то пускай это и будет адрес, не будем жадничать. Тем более, можно будет одни и те же данные интерпретировать разными способами, что наверняка пригодится.

Поскольку это стек, то для удобства дальнейших математических операций загрузим всё в него "задом наперёд". Поэтому сначала идёт X+1 (т.е пропустить основную часть и начать с дуальной), а потом X.

PUSH X+1 вызовет всё ту же процедуру, COMPLEX.PUSH, теперь в регистр AR будет занесён адрес RePrime, с указанием его типа: Int16. Так что здесь будет вызван Int16.PUSH, который, наконец, загрузит значение в стек.

Может показаться перекладыванием из порожнего в пустое, но скорее всего здесь будет чуть более интересный код: предпочтительно будет хранить в стеке значения удвоенной ширины, чтобы не потерять точности на промежуточных вычислениях. Так что PUSH будет заниматься уширением, а POP - округлением результата и отсечением младшей части.

Сложение пока представляю так. Будут вспомогательные процедуры _PLUS, которые при сложении будут "уводить указатель стека" аккурат за пределы всего числа, поэтому "настоящая" процедура PLUS будет общая для всех, и будет сводиться к вызову _PLUS, а затем _FORWARD. Как-то так:

Int16.PLUS:
DUAL.PLUS:
COMPLEX.PLUS proc
[SP++] LR
[SP++] AR
_PLUS [SP]
_FORWARD [SP--]
JMP [--SP]
COMPLEX.PLUS endp

Заметим, что здесь AR без квадратных скобок. Так бывает, когда мы НЕ СПУСКАЕМСЯ НА УРОВЕНЬ НИЖЕ, а вызываем другие процедуры НА СВОЁМ УРОВНЕ. Т.е команда PLUS DC2 вызывает в итоге DUAL.PLUS, а он, в свою очередь, вызывает DUAL._PLUS, а за ним: DUAL._FORWARD. Регистр AR очень "нестабилен", затирается при каждом вызове процедуры (заменяясь аргументом этой процедуры), поэтому здесь мы его сохраняем в стек и берём оттуда.

Реализуем _PLUS для обычных 16-битных чисел:

Int16._PLUS proc
Acc [--DSP]
ADD [AR]
[DSP] Acc
JMP LR
Int16._PLUS endp

И для комплексных и дуальных:

DUAL._PLUS:
COMPLEX._PLUS proc
[SP++] LR
[SP++] X
X [AR]
_PLUS X++
_PLUS X
X [--SP]
JMP [--SP]
COMPLEX._PLUS.endp

Итак, при вызове PLUS DC2 из кода "верхнего уровня", сначала вызывается DUAL.PLUS, который, в свою очередь, вызывает DUAL._PLUS. Там в регистр AR (Argument Register) загружается адрес основной компоненты, вместе с её типом, в данном случае COMPLEX. И для неё вызывается COMPLEX._PLUS. Тот, в свою очередь, загружает в AR адрес RePrime, и значение оттуда складывается с последним элементом стека. Сразу после этого вызывается _PLUS для следующего адреса (ImPrime), и это значение складывается с предпоследним элементом. Вот так, тихой сапой, всё и сложится...

Процедура _Forward должна будет прокрутить DSP (Data Stack Pointer) на правильную позицию, ведь он 4 раза подряд смещался "вглубь" стека. Но это оставим на следующий раз, как и умножение.

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

Poll Иерархия математических структур в QuatCore

кватернионы-это просто (том 1), странные девайсы, математика, ПЛИС, программки, работа

Previous post Next post
Up