
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 💻

