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

Перейду сразу к делу и скажу, почему я так сильно люблю этот алгоритм, а затем займёмся изучением принципов его работы. По сути, фактические операции CORDIC весьма просты — как я уже сказал, это сдвиги и сложение — но выполняет он их путём комбинирования векторной арифметики, тригонометрии, доказательств сходимости и продуманных техник компьютерных наук. Лично я считаю, что именно это имеют ввиду, описывая его природу, как «элегантную».

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

▍ Избегание плавающей запятой


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

У вас может возникнуть вопрос, каким образом нам удастся избежать плавающей запятой, когда функции вроде sin(x) создают значения в диапазоне от -1,0 до 1,0? Что ж, плавающая запятая — это не единственный способ представления рациональных чисел. В действительности, до популяризации стандарта IEEE 754 всегда использовалась именно фиксированная запятая (можете спросить разработчиков из геймдева, которые создавали игры между 1980-ми и 2000-ми годами).

Лично я сильно погрузился в изучение CORDIC после прослушивания фантастического подкаста Дэна Мангума, посвящённого Microarch Club. В нём Филип Фрейдин обронил острую фразу, мол: «Плавающая запятая — это костыль», а её использование может быть признаком того, что вы не особо понимаете алгоритм, с которым работаете. Естественно, нужно пояснить, что всё это было сказано в контексте обсуждения кастомных ASIC-карт, а не типичных веб-приложений, но сама фраза меня сильно зацепила.

Как же работает фиксированная запятая? Мы берём целочисленный тип вроде int32_t и обозначаем его старшие 16 бит как целую часть, а младшие 16 бит — как дробную. Это число можно поделить и по-другому (например, 10 бит выделить под целую часть, а 22 под дробную), но мы в качестве примера используем соотношение 16/16.



Таким образом мы получаем диапазон примерно от -32768,99997 до 32767,99997. Мы зафиксировали запятую в позиционном представлении числа на 16 битах, хотя, опять же, можно было поставить её в любом месте. Перемещение запятой позволяет нам при необходимости корректировать точность (то есть выделять больше битов для целой части или дробной).

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


Как привести число в этот формат? Что ж, у нас есть 16 бит дробного представления, значит, берём значение, например 42,01, и масштабируем его на (1 << 16). После приведения к типу int32_t это даёт нам 2753167. Если мы захотим вернуться от фиксированной запятой к плавающей, то делаем обратное: 2753167 / (1 << 16) даёт ~42,0099945, что очень близко к 42,01.

#define SCALING_FACTOR (16)

static inline int32_t fixed_from_float(float a) {
  return (int32_t)(a * (float)(1 << SCALING_FACTOR));
}

static inline float fixed_to_float(int32_t a) {
  return (float)a / (float)(1 << SCALING_FACTOR);
}

Также можно полностью отказаться от плавающей запятой и закодировать число, например 1,5, вручную. Его целая часть — это 1, её мы масштабируем на ((1 << 16)), а дробная часть представляет середину между 0x0000 и 0x10000, то есть это будет 0x8000. Таким образом, в десятичном виде мы получим 98303.

Операции вроде сложения и вычитания работают без проблем (в оригинале «Just Work™», — прим. пер.) — при условии использования для всех чисел одного и того же коэффициента масштабирования. Коэффициенты масштабирования можно смешивать и сопоставлять, но это привносит лишнюю сложность.

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

static inline int32_t fixed_multiply(int32_t a, int32_t b) {
  return ((int64_t)a * (int64_t)b) >> SCALING_FACTOR;
}

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

static inline int32_t fixed_divide(int32_t a, int32_t b) {
  return ((int64_t)a << SCALING_FACTOR) / (int64_t)b;
}

Хорошо, с базовыми операциями разобрались. Но что, если мне нужно нечто посложнее, например, тригонометрическая функция? Здесь-то и появляется CORDIC.

▍ Алгоритм CORDIC


CORDIC означает «co-ordinate rotation digital computer» (цифровой вычислитель поворота в системе координат) и был придуман в середине 50-х (хотя общий его принцип был известен математикам не одну сотню лет). Основная идея в том, что мы можем поворачивать вектор вокруг единичной окружности на всё меньшие углы, и в результате компоненты вектора окажутся синусом и косинусом нужного нам угла.


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


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


Но пока забудем об этом и представим более общую картину. Сейчас вполне очевидно, что поворот, скажем, на 22,5˚ — это то же самое, что поворот на 45˚ с последующим поворотом на -22,5˚. То есть мы можем разделить вращение на меньшие части — как положительные, так и отрицательные.

Предположим, что максимальный поворот составляет 90˚ (?/2 радиан), и мы пытаемся узнать sin(0,7) (около 40˚). Начиная с вектора (1, 0) и цели в 0,7 рад, мы делаем поворот на 0,7853 рад (45˚) против часовой стрелки.


Теперь цель представляет 0,7 — 0,7853 = -0,0853. Поскольку значение отрицательное, очередной поворот производим по часовой стрелке на 0,3926 рад (22,5˚). Цель стала -0,0853 + 0,3926 = 0,3073, то есть положительна, значит следующий поворот будет против часовой стрелки на 0,1963 рад (11,25˚).



Если продолжить этот процесс в течение 16 итераций, то вектор практически идеально совпадёт с исходным целевым углом. Значение вектора ~=sin(a), а x ~= cos(a)! Именно так работает CORDIC; мы вращаем вектор в разных направлениях, и в качестве состояния сохраняется аппроксимация различных тригонометрических функций.


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


Теперь у нас есть несколько констант, но также по-прежнему есть tan(a) и cos(a). Давайте проигнорируем cos(a) и постараемся избавиться от tan(a). Как вы видели при разборе алгоритма, мы всегда выполняем поворот в общем на ~90˚: сначала на 45˚, затем на 22,5˚, потом на 11,25˚ и так далее. Поскольку мы проделываем это фиксированное число раз, то можно просто заранее вычислить эти значения и внести их в таблицу. Вы можете подумать: «Ты сказал, что не будет никаких таблиц!» Не совсем. Я сказал, что не будет затратных таблиц. Эта же таблица в нашем случае будет содержать лишь 16 чисел uint32_t, занимающих аж целых 64 байта. Даже самые ограниченные встраиваемые решения обычно могут себе такое позволить. (К сравнению, неоптимизированная таблица для sin(x), содержащая 4096 записей значений от -1 до 1, потребует 16 КиБ — и это при довольно низкой точности).


Это означает, что наша матрица вращений содержит только константы. Тем не менее у нас всё равно остаётся член cos(a). В действительности, каждая итерация привносит новый член cos(a). Но, благодаря алгебре, можно просто умножить эти члены друг на друга и применить их в конце.


Хотя и это не очень хорошо. Но! Независимо ни от того, делаем мы положительные или же отрицательные шаги, ни от количества итераций, эта перемноженная серия косинусов по факту сойдётся к константе: ~0,6366. Нам лишь нужно после всех итераций умножить результат на это значение.


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

▍ Сдвиги и сложение


Можно ли углы, которые мы подставили в tan(a), вместо этого стратегически выбрать так, чтобы результат всегда был равен обратной степени 2? Было бы здорово, поскольку умножение или деление на степень 2 для целых чисел представляет просто сдвиг влево или вправо.

Что ж, это как раз поможет проделать функция atan(x) (арктангенс или обратный тангенс). Можно построить новую таблицу из 16 записей, в которой каждое значение будет равно atan(2**-i) при i в диапазоне от 0 до 15. Теперь фактические значения вращения для каждой итерации будут следующими: 45˚, 26,565˚, 14,036˚, 7,125˚ и так далее.

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

Нам по-прежнему нужно повторно вычислить нашу константу для членов cos(a). Сейчас она получится равной примерно 0,60725, и это значение можно преобразовать в число с фиксированной запятой 39796. Кроме того, есть один приём, который избавляет нас от необходимости умножения на это значение в конце. При инициализации вектора нужно установить x не на 1, а на эту константу.


Теперь алгоритм CORDIC выглядит так:

  • Предварительно вычисляет таблицу для tan(a), в которой каждая запись представляет atan(2**-i). Эти значения, естественно, преобразуются в числа с фиксированной запятой, значит atan(2**-i) * (1 << 16)
  • Затем, когда нам нужно вычислить синус или косинус, мы берём угол (например, 0,9152) и преобразуем его в значение с фиксированной запятой: 0,9152 * (1 << 16) = 59978.

Далее прописываем начальные параметры:

x = 39796
y = 0
z = 59978

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

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

if z >= 0:
    x_next = x - (y >> i)
    y_next = y + (x >> i)
    z -= table[i]
else:
    x_next = x + (y >> i)
    y_next = y - (x >> i)
    z += table[i]
x = x_next
y = y_next

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


Во время первой итерации z был положительным, поэтому вектор повернулся на ~0,785 рад против часовой стрелки. Заметьте, что при этом он увеличился.


При второй итерации z также оказался положительным, поэтому вектор снова был повёрнут против часовой стрелки, но уже на ~0,436 рад, и на этот раз проскочил целевую отметку. Теперь величина вектора равна почти единице — это произведение cos(a) начинает сходиться после установки изначального значения x.


В третьей итерации z был отрицательным, поэтому вектор повернулся по часовой стрелке на ~0,244 рад. Он явно начинает подбираться к целевой отметке, и вы видите, что до получения очень близкой аппроксимации осталось буквально несколько итераций.


На четвёртой итерации z снова был отрицательным, что привело к повороту по часовой стрелке на ~0,124 рад. Теперь, когда изменение угла становится очень небольшим, и вектор очень близок к нужному результату, операции вращения продолжают гонять его туда-сюда, всё ближе подводя к целевому значению.


Перейдём к последней итерации. Теперь y содержит очень точную аппроксимацию для sin(0.9152) — с абсолютным отклонением всего в 0,00000956. Отклонение значения косинуса (в x) чуть выше и составляет 0,0000434, но всё равно весьма неплохо!


▍ Подытожим


Об алгоритме CORDIC можно сказать намного больше. Возможно, я затрону эту тему в будущей статье. Например, я не упомянул особые нюансы, которые необходимо учитывать, если нужный угол находится вне первого или четвёртого квадранта единичной окружности. Я также не говорил о том, как с помощью нескольких изменений можно настроить CORDIC для вычисления многих других функций, включая tan, atan, asin, acos, sinh, cosh, tanh, sqrt, ln, e^x. Существуют и другие похожие алгоритмы, такие как BKM, созданный специально для вычисления логарифмов и степеней.

Я планирую достаточно детально раскрыть эту тему на своём YouTube-канале Low Byte Productions, так что приглашаю на него всех интересующихся.

Telegram-канал со скидками, розыгрышами призов и новостями IT ?

Комментарии (24)


  1. NetBUG
    19.05.2024 10:21
    +16

    Собственно, тема-то осталась не раскрыта: вы прекрасно объяснили работу алгоритма, но не объяснили, чем он для вас так важен :)

    Сам алгоритм мы могли наблюдать в компьютерах 70-80-х, микрокалькуляторах, в libcordic в Linux, факт. (интересно, как работали FPU типа 80287, 80387)


    1. Gay_Lussak
      19.05.2024 10:21
      +8

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


      1. fivlabor
        19.05.2024 10:21
        +4

        Ну не то, чтобы вытаскивают. Если надо крохотного размера, быстро и дешёво вычислять (например, векторное управление BLDC с частотой сотни кГц), можно взять микроконтроллер с аппаратным CORDIC. ( https://brushless.zone/stm32g4-cordic-vs-sinf-for-motor-control/ ). Хотя в нем и на таблицы памяти хватает и FPU есть, но это, всё равно, дольше.

        Одно плохо - stm32g431 сложно в РФ купить за адекватные деньги


        1. Gay_Lussak
          19.05.2024 10:21
          +2

          Аппаратный будет быстрее выборки по таблице? Не знаю как реализован на stm CORDIC, но в целом скорость вычисления зависит от разрядности, т.е. если мы хотим получить 16-битное значение, то должны ждать 16 тактов.


          1. Yuri0128
            19.05.2024 10:21
            +2

            Не знаю как реализован на stm CORDIC

            там реализован 24-битный юнит и в базе требует 29 циклов: https://www.st.com/resource/en/application_note/an5325-how-to-use-the-cordic-to-perform-mathematical-functions-on-stm32-mcus-stmicroelectronics.pdf


            1. S_WW
              19.05.2024 10:21

              Я проверял скорость вычисления синуса на STM32G474. Там в регистр аргумента записывается угол, после чего в регистре состояния нужно ждать появления бита готовности. Я в цикле ожидания инкрементировал переменную чтобы узнать да сколько циклов вычислится синус. Так вот, количество циклов равно нулю! То есть бит готовности появляется практически сразу.


              1. Yuri0128
                19.05.2024 10:21

                Тут такое.... В STM32G474 база Cortex-M4F, - соответственно в списке инструкций FPU синуса нет.... Совсем нет. Убеждаемся.

                Что вы там намерили - ну, по крайней мере непонятно.

                А в модуле Cordic - все-таки 29 циклов. Проверять - на ассемблере.


                1. S_WW
                  19.05.2024 10:21

                  Конечно нет, так же как нет регистра состояния и бита готовности. Я писал про модуль CORDIC, который есть в этом микроконтроллере. И про его быстродействие. И проверять это можно на чём угодно.


                  1. Yuri0128
                    19.05.2024 10:21

                    И проверять это можно на чём угодно.

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

                    Таймером проверяется а не переменной.


                    1. S_WW
                      19.05.2024 10:21

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


                      1. Yuri0128
                        19.05.2024 10:21

                        Собственно я именно об этом. 170 нс.


          1. NetBUG
            19.05.2024 10:21

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


          1. fivlabor
            19.05.2024 10:21

            Выборка по таблице - это несколько операций сравнения (хоть и двоичным поиском) для поиска соседей, а потом ещё несколько float-операций для интерполяции.

            Помню неск лет назад в одном проекте драйвера BLDC на stm32f4xx использовали как раз табличный метод, коллеги оценивали гарантированную частоту обработки в 32 кГц


        1. Dozer88
          19.05.2024 10:21

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


          1. Gay_Lussak
            19.05.2024 10:21
            +1

            Ну тут такая ситуация. Там где используется STM32 обычно хватает просто таблицы и CORDIC просто не нужен. Но там где он нужен, уже STM32 не вывозит. Это какой-нибудь широкий FFT, который скорее всего будет имплементирован на FPGA и в такой ситуации просто не будет столько памяти хранить комплексные коэффициенты. Тут уже надо считать коэффициенты на лету.


          1. Yuri0128
            19.05.2024 10:21

            Ну, все-же sinf() работает несколько подольше 29 циклов.....

            По поиску в большой таблице, которая находится у Бога на куличках (8 к значений во внешнем ОЗУ/ПЗУ) - думаю, что тоже будет дольше 29 циклов (честно признаюсь - не проверял....). Так-что вариант фактически безальтернативный по скорости для данного контроллера (ну, если устраивает "приблизительность" и фиксточка).


          1. S_WW
            19.05.2024 10:21
            +1

            FPU-то там есть, но синуса нет. А библиотечная функция в итоге работает медленнее, чем CORDIC. И, как было справедливо замечено, float - это костыль, который программисты используют когда до конца не понимают алгоритм. Ведь результат вычисления синуса нужен в конечном итоге для записи в таймер очередного значения ШИМ, или для регистров другой периферии, но в любом случае это будет целое. Тогда зачем нужны лишние преобразования?


            1. Dozer88
              19.05.2024 10:21
              +1

              Костыль, но тянуть 64-и-более-разрядные вычисления из-за большого динамического диапазона (например в модемах) - дорогое удовольствие. И постоянно следить, чтобы разрядности хватало... На FPGA - понятное дело, а на MCU более 32 разрядов жутко медленно, float работает за 1 такт. Если тригонометрия табличная, то взять значение - тоже единицы тактов


      1. MrPotato
        19.05.2024 10:21
        +3

        Напрасно Вы так про "запылившийся ящик". Применение CORDIC не ограничено вычислениями элементарных функций. Напомню, что Волдер/Меджитт называли этот метод методом псевдоповоротов вектора. Например, сейчас этот метод широко используется в ФАР для фазирования и весовой суммирования, причём он удобен для построения конвейерного процессора формирования ДН. А "вектор" может быть не обязательно двумерный. Вся быстрая матричная арифметика, связанная с преобразование базовой системы координат делается на этом алгоритме, список просто бесконечно - там просто не паханое поле... А Вы про "ящик". Я думаю, что Вы пересмотрит Ваше отношение.


    1. AlexanderS
      19.05.2024 10:21
      +1

      В самом начале же написано:

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


  1. Panzerschrek
    19.05.2024 10:21

    А что там с аппроксимацией полиномом? Если привести значение угла в диапазон от -pi/2 до pi/2, то можно использовать полином не очень большой степени. Или он будет всё же медленнее вышеописанного способа?


    1. multiprogramm
      19.05.2024 10:21

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


  1. AKudinov
    19.05.2024 10:21

    Тут важно то, что CORDIC очень хорошо ложится на FPGA, которая, по своей сути, является схемой, а не процессором. И в таком применении альтернатив у него практически нет. Поставить память, в которой держать значения, например, для 256 углов? А если нужно большее разрешение/точность? Пристроить к памяти автомат, который делает интерполяцию?


  1. domix32
    19.05.2024 10:21

    Несколько странно, конечно, что вы по статье решили использовать запятую в качестве разделителя.