Некоторое время назад понадобилось мне в одной Delphi-шной программе много посчитать, но расчеты шли как-то подозрительно долго. Переписывать около 100 kLOC не хотелось- особенно из-за наличия большого количества форм, а предыдущий мой опыт показывал, что если код расчетов перекомпилировать в Lazarus'е (с FPC3.0.4)- то скорость счета возрастает до 2-х раз, и поэтому было очевидно, что конкретно в данном случае компилятор Embarcadero (разных версий) сильно несилен, и надо его менять. С другой стороны, IDE от Embarcadero для быстрого рисования GUI- вне конкуренции, а их компилятор на редкость быстрый (оно и понятно- быстро+плохо, или медленно+хорошо). Но ведь вкус кактуса неимоверно притягателен. Профайлинг подручными средствами (tinyprofiler) во всех случаях показывал, что основное время (90%) занимают операции векторной алгебры над большими массивами чисел, а быстрый тест производительности этих процедур показал, что на операциях с этой алгеброй общая "пропускная способность" имевшегося математического ядра составляет для операций типа умножения векторов и скалярных произведений- ~4 ГБ/с, для умножения вектора на матрицу- 1,5-2 ГБ/с, а вот для операций обращения матрицы- проседает до 360 МБ/с (на Core I5 4460 и на Xeon 2660V2, DDR3-1866). Внутре рядом с неонкой используются только 3-х и 4-х мерные вектора и матрицы. GUI и ввод-вывод крутятся в своем потоке, а расчеты- крутятся в своих потоках, и друг другу не мешают. В голову пришла мысль, что 4х4 матрица- должна целиком влезать в SSE-регистры процессора и для нее SIMD-очень желательны, а Embarcadero в компилятор SIMD не завезли, не завезут, и вообще- дальше нижней половины XMM0 не используют. В итоге нарисовалась очень простая задача- реализовать быструю векторную алгебру в минимальном объеме для 3D/4D векторов своими руками- то есть, соорудить стероидный велосипед, о котором в заголовке написано. И вот здесь мой навык гуглинга дал сбой- то есть, навык-то есть- мне предлагали купить проприетарные библиотеки, в которых все уже есть, или примеры векторных операций с FP32 на SSE, но примеров под FP64- не было. Теперь будет. Под катом- как на SSE4.2 руками сделать операцию с вектором для расчетов какой-нибудь физики и вывернуть матрицу наизнанку. Как я отметил, на тот момент в наличии были Core I5 4460 & Xeon 2660v2, которые поддерживают SSE4.2, но еще не имеют поддержки FMA и AVX, поэтому решать задачу будем на том, что есть, но по ходу пьесы я отмечу те места, где AVX мог бы быть полезен и объясню, почему он бесполезен. Впрочем, объясню сейчас- AVX в контексте бесполезен потому, что даже SSE4.2 позволяет средненькому процу переваривать данные быстрее, чем они пролазят через память, даже одно ядро Xeon'а на 3,3 ГГц обрабатывает ~10ГБ/с, а целый процессор забивает все 4-ре канала памяти и успевает покурить в перерывах, если вся задача не влезает в кэш (у Ryzen 3900 кэша 64МБ, и в такой кеш влезает довольно много, потому на нем смысл в AVX может и возникнуть, но тогда ботлнек случается на переключении между потоками, и заметного выигрыша мне все равно не получить). Итак, к делу. Минимальные начальные данные: В нашем процессоре имеется SSE4.2. А это значит, что нам доступны 16 XMM регистров, каждый из которых может содержать по 2 FP64-числа, и всего в XMM мы можем разместить 32 таких числа. Немножко огорчает тот факт, что мы не можем просто так напрямую быстро и эффективно перекидывать данные из XMM в обычные регистры и обратно, но 32 числа- это все-таки довольно много. Регистры пронумерованы от XMM0 до XMM15. Каждый регистр содержит 128 бит, которые могут рассматриваться по-разному- как просто пачка бит, как 2 FP64, как 4 FP32, как 4 Int32, и много еще как, но нас будет интересовать именно 2 FP64- так как для расчетов физики в моем случае используются только они.   Для таких чисел в SSE4.2 есть ряд очень приятных инструкций, а именно: movupd XMM4, oWORD[ Pk ] - MOVe Unaligned Packed Double загружает в регистр XMM4 octo-WORD, то есть- 16 байт, из адреса [Pk], причем, загружает их невыровненные, то есть, этот адрес может быть любой, и как бы напоминает нам, что это не просто какие-то байты, а это два вещественных числа. movapd XMM4, oWORD[ Pk ] - все то же самое, но адрес должен быть выровнен (Aligned) в памяти на 16, если подсунем невыровненный- получим исключение, а если выровненный- то загрузим данные чуть-чуть быстрее, чем movupd. Если в этих инструкциях поменять адрес с регистром местами- то тогда вместо загрузки данных мы получим их сохранение из регистра в память. movsd XMM4, qWORD[ Pk ] - MOVe Single Double загружает в нижнюю половину регистра XMM4 quad-WORD, то есть- 8 байт, из адреса [Pk], как бы напоминает- что это именно FP64 число, верхнюю половину не трогает, и к выровненности требований не предъявляет. Можно сделать movsd XMM0, XMM1- тогда она переложит одно нижнее число из XMM1 в XMM0. Ее аналог- movlpd, но она не умеет перекладывать из регистра в регистр, только из/в память. movlhps XMM0, XMM1 - MOVe Low to High Packed Single - внезапно, берет нижнюю половину XMM1 за два вещественных одинарной точности, и перекладывает их в верхнюю половину XMM0. но так как на самом деле это все просто 64 бита- то получается, что для нас она перекладывает одно двойное число из нижней половины в верхнюю. причем, можно использовать ее в виде movlhps XMM0, XMM0- тогда мы продублируем нижнее число регистра в его же верхнюю половину, и получим два одинаковых числа в одном регистре. movhlps XMM0, XMM1- наоборот- переложить верхнюю половину одного регистра в нижнюю половину другого (можно и этого же). mulpd XMM0, XMM4- ооо, это одна из наших любимых инструкций на ближайшее время, MULtiply Packed Doubles- в XMM0 у нас лежат (X0, Y0), в XMM4 у нас лежат (X1, Y1), эта инструкция попарно их перемножает и складывает результат снова в XMM0- то есть, после ее выполнения в XMM0 будет лежать ( X0X1, Y0Y1)- два умножения за время одного! addpd XMM0, XMM1 - вторая любимая жена инструкция- ADDition Packed Double- после нее XMM0 = ( X0+X1, Y0+Y1). Иногда бывает еще нужно mulsd/addsd XMM0, XMM1- перемножить/сложить нижние числа регистров и положить результат в нижнюю половину первого операнда. Еще одна очень полезная инструкция haddpd XMM0, XMM2 - Horizontal ADDition Packed Double: если XMM0 = (x0,y0), а XMM1 = (x1,y1), то после выполнения этой инструкции XMM0 = (x0+y0, x1+y1)- два сложения за такт по горизонтали (в отличие от addpd, которая складывает "по вертикали"). ну и наверное, последняя из интересных инструкций dppd XMM0, XMM6,  49 // imm8 = bit0 | ~bit1 | bit4 | bit5  Dot Product Packed Double - скалярное произведение приемника и источника с маской. Маска выбирает, какие части регистров перемножать, как складывать и куда сохранять результаты. Маска 49- говорит, что нужно X0X1+Y0Y1 положить в нижнюю половину регистра приемника.  divpd/divsd - как нетрудно догадаться, противоположны mulpd/mulsd и выполняют деление- но они медленные! латентность и время выполнения у них в четыре-пять раз выше. Так как речь идет о математике, то все функции и процедуры базовых вычислений по умолчанию были с директивами inline и register. Инлайн избавляет от затрат на вызовы в циклах, а register- заставляет компилятор пересылать данные через регистры процессора, а не стек, что несколько быстрее. В документации Delphi сказано, что параметры передаются через регистры, если их меньше пяти, а начиная с пятого- через стек- это надо иметь в виду. Еще в x64-mode Delphi не умеет ассемблерные вставки (только процедуры целиком) и не умеет инлайн ассемблерных функций, так что при переходе на ассемблер придется обходиться без inline- а значит короткие функции будут иметь большой штраф на вызов, и желательно обрабатывать сразу большие пачки данных, чтобы его скомпенсировать :-(.  Хороший лонгрид для начала с ассемблером... Документация от Embarcadero Предполагая, что читатель знаком с ассемблером или прочел лонгрид, давайте быстренько перемножим два массива чисел: Массивы у нас лежат в памяти одним куском, но для многопоточности их придется обрабатывать по частям, многопоточностью управляет другой код, а здесь мы запрашиваем диапазон элементов [i0, i1], которые нужно обработать. .NOFRAME говорит компилятору, что данная функция не вызывает других функций и не передает параметры через стек, и потому для нее не надо генерировать фрейм стека, что экономит немножко времени на его создании/удалении, в данном случае пренебрежимо мало, но если бы мы обрабатывали очень короткие пачки данных- то эффект становится заметен, хотя и остается на уровне каких-то процентов. .PUSHNV R13 говорит компилятору, что перед вызовом нашей функции надо сохранить на стек регистр R13, а после вызова- восстановить его значение, позволяет не париться вопросом- у всякого-ли пуша есть своя попа. для XMM-регистров есть своя, аналогичная директива .SAVENV XMM5 Документация Embarcadero говорит, что при вызове функций регистры XMM0-XMM4 сохраняются и восстанавливаются вызывающим кодом, поэтому об их целостности можно не беспокоиться, а регистры XMM5-XMM15 должны сохраняться и восстанавливаться вызываемым кодом, и поэтому, если мы их используем, то чтобы не поломать что-то снаружи нужно позаботиться об этом, иначе наша процедура может затереть данные, положенные в эти регистры кем-то другим. mov R13, Self.fRes - требует объяснения- все данные у меня завернуты в объект, Self передается в метод обычно первым параметром через RCX, fRes- имеет какое-то смещение относительно него и его даже можно рассчитать, но Delhpi упрощает мне жизнь, позволяя из своего ассемблера обращаться к переменным и полям по их обычным именам. А так как разные версии компилятора иногда кладут эти переменные по разным регистрам (вопреки документации), то при разработке на нескольких машинах могут возникать странные баги если верить в то, что Self всегда будет в RCХ, а i0- в R8d. Обращение же по имени переменной всегда нормально обрабатывается. Но если придется переносить этот код в FPC- то такое уже не взлетит, придется передавать сами массивы в качестве параметров, да там и вообще синтаксис ассемблера другой. Cначала мы бежим по нашим данным, загружаем буфер по N=2 числа, перемножаем их тоже пачками, а результаты перекидываем в память и уменьшаем счетчик оставшихся элементов на N. Если останется меньше N элементов- то загрузить полный буфер не удастся, тогда обработаем элементы поштучно, а если останется 0- то мы просто прыгнем на finish. Вместо N=2 легко сделать N=4 (скорости это не добавляет). На данном коде ускорение по сравнению с обычной (компиляторной) версией примерно в 1.5 раза. Я говорил, что Lazarus мне давал ускорени в 2 раза- но там код брал данные, раскиданные в памяти как попало, и Лазарус почему-то реализовал это сильно эффективнее. Здесь у нас данные лежат в памяти кучно- КМК- компилятору негде особенно косячить, поэтому хуже он просто не смог.  Теперь чуть медленнее попробуем умножить кучу векторов на константу. Так как с выровненными на 16 векторами работать удобнее- то все вектора были превращены в 4-D-вектора, но трехмерный код игнорирует четвертую компоненту (а иногда использует ее для каких-нибудь промежуточных данных, в нее можно скинуть массу частицы или энергию, например, или длину вектора сохранить). Код получился почти одинаковый- но это понятно, так как массив векторов- это, фактически, просто в 4-ре раза удлиненный массив скаляров. Вектора хорошо, но нам нужны еще и тензоры второго ранга в 3-мерном пространстве (тензора деформаций и напряжений, например, или инерции): Две часто встречающихся операция с ними- а.i += c.jB.ji и a.i += B.ijc.j- то есть, добавление к вектору правого или левого матричного произведения. Обратите внимание- во второй процедуре есть код типа ADD C_i, FloatSize, хотя С_i описана как const C_i. Это работает, так как обе процедуры выше описаны как static; register; и имеют меньше 4-х входных параметров, а это значит, что значение C_i передается указателем (потому что record не влазит в регистр), но при этом кладется в регистр, и ее изменение меняет значение указателя в регистре, но не трогает реальные значения в памяти. Такой же фокус можно делать и с константными значениям других типов, которые целиком влезают в регистр- в ассемблерных процедурах они работают как локальные переменные, но нужно учитывать, что если тип переменной целиком помещается в регистр общего назначения- то вместо адреса будет передано само значение, и если тут ошибиться- то дебаг может быть нетривиальным. Начнем с матриц 3х3. Для вычисления обратной матрицы в этом случае можно использовать два подхода- или использовать прямую формулу- обратная матрица- это транспонированная матрица алгебраических дополнений элементов, деленая на определитель исходной матрицы, или использовать метод Гаусса с расширенной матрицей. По прямой формуле для матрицы 33 a.xx a.xy a.xz a.yx a.yy a.yz a.zx a.zy a.zz обратная матрица будет равна +(A.yy*A.zz - A.yz*A.zy)/D -(A.xy*A.zz - A.xz*A.zy )/D +(A.xy*A.yz - A.xz*A.yy)/D -(A.yx*A.zz - A.zx*A.yz)/D +(A.xx*A.zz - A.xz*A.zx)/D -(A.xx*A.yz - A.yx*A.xz )/D +(A.yx*A.zy - A.yy*A.zx)/D -(A.xx*A.zy - A.xy*A.zx)/D +(A.xx*A.yy - A.xy*A.yx)/D D= +(A.yy*A.zz - A.yz*A.zy)*A.xx -(A.xy*A.zz - A.xz*A.zy )*A.yx +(A.xy*A.yz - A.xz*A.yy)*A.zx; обратим внимание на две вещи-   при расчете определителя в скобках стоят те же самые элементы, что и в первой строке конечной матрицы (а множители у этих скобок- это элементы первого столбца исходной матрицы)  вместо того, чтобы делить на определитель, лучше сначала вычислить 1/D, а потом уже на нее умножать (на Haswell обычная FMUL имеет латентность 5 и требует 1 такт, а FDIV- имеет латентность 10-24 и требует 8-18 тактов! Используемые в SSE mulpd- латентность 5 и 0,5 такта, divsd- латентность 10-20, 8-14 тактов- поэтому везде, где больше одного деления- лучше сначала посчитать 1/D, и потом на нее умножать). Метод Гаусса с расширенной матрицей предполагает, что мы сначала построим матрицу вида a.xx a.xy a.xz 1 0 0 a.yx a.yy a.yz 0 1 0 a.zx a.zy a.zz 0 0 1 над этой матрицей проведем преобразования по методу Гаусса, сначала исключая элементы ниже главной диагонали, затем- выше главной диагонали, и после этого получим матрицу вида:  1 0 0 b.xx b.xy b.xz 0 1 0 b.yx b.yy b.yz 0 0 1 b.zx b.zy b.zz полученная в правой части b- это и есть обратная матрица. Экспериментально проверено: для матриц 3х3 прямой расчет через дополнения выполняется быстрее исключения Гауссом. Кроме того, в методе Гаусса могут возникать нули на главной диагонали, что требует переупорядочивания строк, большого количества условных переходов, проверок, и еще усложняет без того не очень эффективный алгоритм, а прямая формула использует 1 деление, требует только одной проверки (Abs(D)> eps), и потому предпочтительнее. Отметим еще один момент: сама матрица содержит 9 элементов, матрица результатов- еще 9 элементов, а нам доступно 32 элемента, то есть, все расчеты полностью влезают в регистры и остается еще свободное место на хранения промежуточных значений.  Страшный ассемблерный код (с комментариями), реализующий прямой расчет обратной матрицы 3х3 приведен ниже: Хорошо, обращение матрицы 3х3- вещь полезная и часто нужная, но иногда еще нужно обращать матрицы 4х4 (например, при расчете линейной аппроксимации трехмерной функции методом наименьших квадратов на неструктурированном множестве точек).  И тут ситуация в корне изменилась. Во-первых, прямой метод требует расчета алгебраических дополнений- сейчас это стали определители 3х3. Во-вторых, сама матрица занимает уже 16 элементов- ровно половину доступных нам регистров, матрица результата- еще 16 элементов, промежуточные значения хранить уже физически негде, а перекидывать данные из XMM-регистров в обычные и обратно мы не можем. Можно скидывать данные в стек, но тогда реализация станет совсем неподьемной. Что же делать?  К счастью, у меня созрел гениальный план. Во-первых, в моей физике используемые матрицы- обычно достаточно хорошо обусловлены и как правило- имеют диагональное если не преобладание, то как минимум- превалирование (преобладание- это когда элемент больше суммы модулей остальных элементов, превалирование- это когда он больше по модулю любого другого в строке, но не их суммы). Во-вторых- я подумал, что мне совсем не обязательно хранить всю расширенную матрицу, если я использую метод Гаусса. Дело в том, что в методе Гаусса с расширенной матрицей на любом этапе очень много фиксированных значений. Вот смотрите- до начала расчетов : [xx xy] [xz xt] [1 0] [0 0] [yx yy] [yz yt] [0 1] [0 0] [zx zy] [zz zt] [0 0] [1 0] [tx ty] [tz tt] [0 0] [0 1] фиксированные значения я выделил жирным. После зануления первого столбца: [1 xy’] [xz’ xt’] [res.xx 0 [0 0] [0 yy’] [yz’ yt’] [res.yx 1 [0 0] [0 zy’] [zz’ zt’] [res.zx 0 [1 0] [0 ty’] [tz’ tt’] [res.tx 0 [0 1] с точки зрения операций над строками этой матрицы, нам ничто не мешает перекинуть 5-й столбик вместо первого, но тогда у нас снова окажется в левой половине матрица с какими-то значениями, а в правой- та же самая единичная матрица, с которой мы начали- как будто ничего с ней и не произошло. А раз так- то зачем же ее обрабатывать? она же не меняется! после зануления элементов ниже главной диагонали мы получим матрицу: 1 xy xz xt res.xx’ 0 0 0 0 1 yz yt res.yx res.yy 0 0 0 1 1 zt res.zx res.zy res.zz 0 0 0 0 1 res.tx res.ty res.tz res.tt И на любом шаге алгоритма ровно половина элементов имеют точно известные значения (нули и единицы). А это значит, нам на самом деле не нужно хранения двух полных матриц, достаточно хранить только изменяющиеся значения. Кроме того, можно заметить, что элементы в левой и правой частях расширенной матрицы очень удачно дополняют друг друга- если слева- полная матрица- то справа- нет вообще ни одного произвольного элемента, если слева- нижний треугольник занулен- то в правой части именно в этом треугольнике и есть ненулевые элементы. Если на главной диагонали левой части стоят единицы- то на главной диагонали правой части- стоят значения, а если в левой части- значения, то в правой части- единицы. Поэтому можно крутить данные только в одной квадратной матрице, используя для хранения промежуточных вычислений 8 освободившихся XMM-регистров.  Итак, алгоритм у нас получается примерно такой:  Из вспомогательных значений нам нужны 0.0e+0, 1.0e+0 (их можно хранить в 1 XMM-регистре), какой-то регистр для копирования промежуточных значений, регистр, в который мы будем сохранять результаты деления, и у нас остается еще 5 свободных регистров, которые можно использовать (но некуда). Соответствующая портянка ассемблерного кода- под катом.  Здесь по коду раскиданы инструкции вида   prefetch [RCX + 4FloatSize]  данная команда предлагает процессору сделать упреждающую выборку данных из памяти, выполняется параллельно с последующим потоком команд, и при возможности- загружает в кэш процессора данные, которые понадобятся чуть позже, в результате доступ к ним будет происходить чуточку быстрее, но максимум, что я заметил с этих префетчей- это 1-2% ускорения. Еще здесь много операций, которые можно было бы заменить на FMA, которая есть в Ryzen и новых интелях, но после проведения ряда тестов желание использовать FMA у меня пропало.  На момент написания у меня в наличии уже Xeon-2678v3 c DDR3-1866-4x (без анлока турбобуста- почему- в самом конце) и Ryzen 3900x c DDR4-3200-2x. Тесты будем проводить на Xeon-е, а часть интересных особенностей сравним с Ryzen-ом.  Скорость обработки данных определим как объем данных, который мы можем считать и обсчитать за 1 секунду (без учета того, сколько при этом будет записано результатов в память). Все тесты запускались по 5 раз и выбиралось наилучшее значение. Во всех случаях использовались массивы по 1 048 576 элементов. Сумма скалярных произведений элементов двух массивов случайных векторов: Рост производительности просто от использования SSE небольшой, потому что много времени занимает вызов функции SSE-умножения, так как Embarcadero не умеет инлайн ассемблерных функций.  “Dot product: SSE, arr”- это то же самое умножение у которого цикл тоже реализован на ассемблере внутри SSE-блока, как видим, избавившись от вызовов процедуры мы сократили время расчетов на четверть! Нужно учесть, что умножаются два массива векторов, а результат сохраняется в массив вещественных чисел. В данном бенчмарке мы считываем два раза по 4.8 GB/s данных, и потом еще 1200 MB/s записываем, в сумме прокачивая через канал памяти ~11 GB/s с одного ядра.  Умножение вектора на матрицу с суммированием результатов: в данном случае разница связана с тем, что M33*V3 требует умножения элементов строки матрицы на вектор, а V3*33- умножает вектор на элементы столбца матрицы, что требует некоторой реорганизации данных, и снижает итоговую производительность.  Как видим, в моей реализации метод Гаусса на SSE также хорош (или так же плох), как и прямой расчет от компилятора, но вот прямой расчет на SSE- почти в полтора раза быстрее.  А вот здесь уже разница стала подавляющей- ускорение почти в 9 раз! Так как исходная программа довольно много аппроксимировала значения МНК- то для нее расчет обратной матрицы 4х4 был самой тормозной операцией, которая занимала почти 60% времени. после оптимизации она стала занимать порядка 10%, а общая скорость счета выросла примерно в 3 раза.  С матрицам интересно еще вот что: зависимость количества операций, необходимых для инверсии матрицы, от ее размера имеет характер O(n5), то есть, при переходе с М3x3 на M4x4- объем вычислений возрастает в ~4,2 раза, объем данных при этом возрастает в  1,8 раза, скорость обработки данных должна упасть в 2,3 раза, однако, падает только в 1.7 раза, то есть, меньше, чем следует. А связано это с тем, что данные в регистрах лежат плотнее (у M3x3 строка занимает полтора регистра, а у M4x4- ровно два) , и параллельность их обработки в целом получается выше (мы реже выполняем инструкции типа mulsd, addsd, и больше mulpd, addpd).  Для меня неожиданным оказалось то, что при реализации инвертирования матриц пришлось совершенно по другому организовать вычисления- способом, о котором мне не рассказывали в университете на лекциях по линейной алгебре и выч-мату, и который сильно отличается от “компилируемого”- но возник он только после того, как я уперся в ограничение на объем памяти в 256 байт (!) сидя на машине с 10-ю ядрами, 20-ю потоками и 32-мя гигами оперативы.  Стоит ли останавливаться на достигнутом? Сейчас да. Самая низкая скорость обработки данных, полученная нами- это ~3ГБ/с на обращении матриц 4х4. 10 ядерный Xeon 2660v2 на этой задаче может прожевать 25GB/s, что дает суммарный прокачиваемый через шину памяти поток данных в 50GB/s, в то время, как четырех-канальный контроллер памяти DDR3-1866 позволяет примерно столько-же- ~59GB/s в теории, 45-50 в реале. То есть, даже на самых тяжелых расчетах мы упираемся не в ЦП, а в память. Переход на более быструю память DDR4 приведет и к смене процессора на более быстрый и с большим количеством ядер, и как показала практика- Ryzen 3900X  на DDR4-3200 и Xeon 2678v3 - оба все равно продолжают обрабатывать данные быстрее, чем их успевает подавать ОЗУ. 1 поток на Ryzen 3900x инвертирует ~5GB/s матриц 4х4, но процессор имеет лишь двухканальную память DDR4-3200, что позволяет ему ворочать те же самые 50GB/s, то есть, на таких расчетах его можно загрузить почти под завязку- но именно что почти- даже с DDR4-3600 в тестах скорость копирования данных в ОЗУ составляет ~56GB/s- то есть, как минимум одно ядро из 12 будет простаивать, вот и получается, что для нашей векторной алгебры, нужной для метода конечных элементов и аппроксимации частных производных методом наименьших квадратов- дальнейшие оптимизации или переход на AVX не приведут к повышению быстродействия. И даже переход на 4-х канальную DDR4-2400 не меняет ситуацию. А если нет разницы- зачем платить больше? Отсюда же понятно, что турбобуст мне без надобности. Статья немножко опухла, и в нее не влезли векторные произведения векторов, быстрое решение СЛАУ 3х3 и 4х4, умножения разреженных матриц и еще под сотню велосипедных запчастей, но базовые примеры и самое страшное- инвертирование матриц- я описал.