Видимые преимущества языка "C" сопровождается издержками, скрытыми расходами вычислительных ресурсов на указатели, пересылку данных "память <=> регистр", согласование разрядности, выравнивание и т. п.
Разница ожидаемого и реального времени выполнения критических участков кода чувствительна для промышленного программирования бюджетных микроконтроллеров, где главное — низкая цена.
Низкая цена — ключевое преимущество в массовом производстве.
Низкая цена естественно балансируется скромными вычислительными возможностями бюджетных микроконтроллеров.
Все сложные вычисления состоят из конечного ряда простых.
Представление о реальном времени выполнения примитивных операторов языка "C" открывает возможность для экспресс-оценки продуктивности критических участков кода на этапе проектирования через простой подсчёт числа примитивных операторов.
Замеры времени выполнения примитивных операторов языка "C" произведены на двух аппаратных платформах в равных условиях.
Сразу отметим, производительность Cortex M4 выше, чем Cortex M0, что естественно.
Общие наблюдения в результате замеров:
- операции int32 на ~20% быстрее, чем int16;
- double в 2 раза медленнее, чем float на Cortex M0;
- double в 27 раз медленнее, чем float на Cortex M4;
- арифметика float на Cortex M4 конкурентна int16 там же;
- деление - самая медленная операция всегда, что ожидаемо.
Результаты замеров далее в таблицах, где:
- fn: - формула оператора на языке "C";
- cc: - скорость выполнения оператора в тактах CPU;
- us: - скорость выполнения оператора в микросекундах (1E-6).
+=========================================
+-------------- # 1 ---------------------
+-- RELEASE at 12:26:28
+-- CPU:48 MHz, STM32 ARM Cortex M0
+-----------------------------------------
+-- cpu time of simple int16 "C"
fn: i3 = i1 + i2, cc: 7, us: 0
fn: i3 = i1 - i2, cc: 9, us: 0
fn: i3 = i1 * i2, cc: 8, us: 0
fn: i3 = i1 / i2, cc: 67, us: 1
fn: i3 = i1 % i2, cc: 70, us: 1
--- is:715826417 ---
+-----------------------------------------
+-- cpu time of simple int32 "C"
fn: l3 = l1 + l2, cc: 5, us: 0
fn: l3 = l1 - l2, cc: 6, us: 0
fn: l3 = l1 * l2, cc: 5, us: 0
fn: l3 = l1 / l2, cc: 61, us: 1
fn: l3 = l1 % l2, cc: 68, us: 1
--- ls:223077021 ---
+-----------------------------------------
+-- cpu time of simple float32 "C"
fn: f3 = f1 + f2, cc: 139, us: 2
fn: f3 = f1 - f2, cc: 182, us: 4
fn: f3 = f1 * f2, cc: 181, us: 3
fn: f3 = f1 / f2, cc: 568, us: 11
fn: f = (float)l, cc: 110, us: 1
fn: l = (int32)f, cc: 35, us: 1
--- fs:613566756 ---
+-----------------------------------------
+-- cpu time of simple float64 "C"
fn: d3 = d1 + d2, cc: 211, us: 4
fn: d3 = d1 - d2, cc: 235, us: 4
fn: d3 = d1 * d2, cc: 397, us: 7
fn: d3 = d1 / d2, cc: 877, us: 18
fn: d = (doubl)l, cc: 105, us: 1
fn: l = (int32)d, cc: 59, us: 0
--- ds:613566756 ---
+=========================================
+-------------- # 1 ---------------------
+-- RELEASE at 12:32:47
+-- CPU:48 MHz, STM32 ARM Cortex M4
+-----------------------------------------
+-- cpu time of simple int16 "C"
fn: i3 = i1 + i2, cc: 7, us: 0
fn: i3 = i1 - i2, cc: 6, us: 0
fn: i3 = i1 * i2, cc: 7, us: 0
fn: i3 = i1 / i2, cc: 12, us: 0
fn: i3 = i1 % i2, cc: 14, us: 0
--- is:715826417 ---
+-----------------------------------------
+-- cpu time of simple int32 "C"
fn: l3 = l1 + l2, cc: 5, us: 0
fn: l3 = l1 - l2, cc: 4, us: 0
fn: l3 = l1 * l2, cc: 4, us: 0
fn: l3 = l1 / l2, cc: 8, us: 0
fn: l3 = l1 % l2, cc: 9, us: 0
--- ls:223077021 ---
+-----------------------------------------
+-- cpu time of simple float32 "C"
fn: f3 = f1 + f2, cc: 6, us: 0
fn: f3 = f1 - f2, cc: 7, us: 0
fn: f3 = f1 * f2, cc: 5, us: 0
fn: f3 = f1 / f2, cc: 19, us: 0
fn: f = (float)l, cc: 4, us: 0
fn: l = (int32)f, cc: 3, us: 0
--- fs:613566756 ---
+-----------------------------------------
+-- cpu time of simple float64 "C"
fn: d3 = d1 + d2, cc: 120, us: 2
fn: d3 = d1 - d2, cc: 122, us: 2
fn: d3 = d1 * d2, cc: 84, us: 1
fn: d3 = d1 / d2, cc: 688, us: 13
fn: d = (doubl)l, cc: 59, us: 0
fn: l = (int32)d, cc: 31, us: 0
--- ds:613566756 ---
Использованное оборудование:
- ARM Cortex M0 — STM32F030R8T6;
- ARM Cortex M4 — STM32F303VCT6.
Погрешность измерения +/- 1 такт.
Сравнительная таблица результатов.
Простой отказ от int16 в пользу int32 повышает производительность участка программы приблизительно на 20%.
Есть риск свести "на нет" все преимущества FPU на Cortex M4, используя без должной осмотрительности double.
Комментарии (32)
fougasse
27.11.2021 14:40+5Какой компилятор, какие опции, где хотя бы дизасм бинарника?
По поводу неосторожного использования FP — вопросов ни у кого нет, а вот про разницу int16 и int32 уже закрадываются сомнения в правильности измерений(вы сами упоминаете выравнивания и прочее) и тестовой инфраструктуры.
numeric Автор
27.11.2021 15:36про разницу int16 и int32 уже закрадываются сомнения в правильности измерений.
Замедление скорости вычислений для int16 обосновано в ответе на комментарий выше. Там же ассемблерный код для оператора:
L3 = L2 + L1,
где все переменные с типом int32.Для той же формулы с переменными int16, в машинном коде появятся дополнительные команды, преобразующие машинное полу-слово в слово и обратно.
fougasse
27.11.2021 16:15Уже ответили, что есть асм-инструкции для полуслов.
Нужно смотреть машкоды и конкретный тулчейн с его опциями. Ну и «синтетичность» задачи.
Пока выглядит очень странно.
numeric Автор
27.11.2021 17:21Да, нормально оно выглядит. :-)
В ответе выше дизассемблер машинного кода, полученного штатным gcc из исходника на "C" с опцией -O0.
Допускаю, что ручками на макро-ассемблере можно делать более эффективный код, однако тогда теряется смысл языка высокого уровня, а стоимость разработки поднимется на никому не нужную высоту.fougasse
28.11.2021 20:12Зачем смотреть на -О0, вы можете объяснить?
numeric Автор
28.11.2021 22:15Для оценки соответствия машинного кода и кода на "C"; для снижения энтропии системы сборки через исключение модуля искажающего первичную алгоритмическую основу.
Дополнительная аргументация здесь:
https://habr.com/ru/post/591925/comments/#comment_23758799
Afterk
27.11.2021 15:17+4double в 2 раза медленнее, чем float на Cortex M0;
double в 27 раз медленнее, чем float на Cortex M4;
Неудивительно так как у Cortex M0 нету FPU, и double и float вычисляются прогаммно. А вот Cortex M4 имеет FPU, но SP и потому float намного быстрее double. Так что
Есть риск свести "на нет" все преимущества FPU на Cortex M4, используя без должной осмотрительности double.
нету никакого преимущества FPU на Cortex M4 на double.
Если нужен DP FPU, надо смотреть на Cortex M7
numeric Автор
27.11.2021 15:42О том, что в премиальном сегменте "железа" больше возможностей - спору нет.
Однако, в статье речь о бюджетных MCU, когда главное преимущество - низкая цена.
beeruser
28.11.2021 00:33+1Дичь.
fn: i3 = i1 + i2, cc: 7, us: 0 fn: i3 = i1 - i2, cc: 9, us: 0 fn: i3 = i1 * i2, cc: 8, us: 0
Ничего, что команды сложения, вычитания и умножения на M0 выполняются за одинаковое время - 1 такт?
Достаточно ознакомиться с документацией.
developer.arm.com/documentation/ddi0432/c/CHDCICDF
Какой смысл измерять одиночные операции, которые обложены LDR/STR?
Если всё что делает микроконтроллер в вашей программе это выполняет одну(!) арифметическую операцию, о какой оптимизации идёт речь? Оно вам не нужно.Простой отказ от int16 в пользу int32 повышает производительность участка программы приблизительно на 20%.
Вот тут уже речь идёт об «участке программы». О каком участке?
Да, за счёт дополнительного расширения знака 16-битные вычисления будет медленнее, но это нужно смотреть конкретный код, а не писать цифры «с потолка».
Зачем вы показываете дизасм с -O0, если запускаете с -Os?Допускаю, что ручками на макро-ассемблере можно делать более эффективный код, однако тогда теряется смысл языка высокого уровня, а стоимость разработки поднимется на никому не нужную высоту.
Не нужно ничего писать.
Вышеозначеный древнючий GCC 5.4 с ключом (-mcpu=cortex-m0 -mthumb -Os) генеритldrh r2, [r0] ldrh r3, [r1] adds r3, r2, r3 strh r3, [r0]
numeric Автор
28.11.2021 02:12-1Ничего, что команды сложения, вычитания и умножения на M0 выполняются за одинаковое время - 1 такт?
Согласен. В теории Вы правы и документация верна.
На практике простейшая операция выполняется минимум за 4 такта: два такта на загрузку двух операндов из памяти в регистры, один такт, как Вы правильно заметили, уходит на саму примитивную операцию, и один такт на возврат результата из регистра в память.
В эти 4 такта может легко вклинится обработчик прерывания, если ему приспичит. Есть множество других, объективных причин, влияющих на время выполнения простейших операций.
Однако вопрос не в том, как с этим бороться, а в том как это учитывать, планируя гарантированную производительность бюджетного устройства.fougasse
28.11.2021 17:56И какая разница? У вас для 32 бит из памяти в регистры и обратно нет операций загрузки? Между ними не «вклинивается обоаботчик прерываний, если ему приспичит»?
Бороться и учитыватт можно например алгоритмически, можно запрещая прерывания, можно ещё множеством способов если нужна гарантированная производительность.
Пока же вы меряете непонятно что, показывая какие-то обрывки ассемблера, которые никто воспроизвести не может(включая здравый смысл).
numeric Автор
28.11.2021 19:11Вы правы, есть загрузка из памяти в регистры, и прерывания вклиниваются когда посчитают нужным.
В статье о поиске способа простого планирования и учёта времени работы ответственных участков кода оставаясь в парадигме языка "C".
Говоря о планировании, подразумеваем не обязательство, а цель, допускающую отклонение в пределах установленного допуска.
Это значит - без запретов прерываний, без порочного ожидания "delay()" и т.п.
Предположительное начальное приближения к решению задачи - подсчёт числа простейших арифметических операторов на языке "С" на контрольном участке кода.
Под простейшим оператором понимаем конструкцию типа: A = B + C, где A, B и C - переменные в RAM.
Время срабатывания простейших арифметических операторов "C" поддаётся измерению с удовлетворительно точностью (+/- 1 такт) без понижения абстракции до уровня ассемблера и машинных команд, в контексте синтаксиса "С".
При этом работа оптимизатора - последнее дело. Ибо, важнее на данном этапе смысловая проработка задачи и статистическая устойчивость атомарного замера малых промежутков времени без оглядки на платформу MCU и параметры сборки.
grossws
28.11.2021 21:15+1А потом выясняется что где-то ldr был из внешнего sdram и здравствуй добрая сотня тактов. А ваша переменная в памяти могла оказаться в l1d (который может присутствовать в конкретной имплементации контроллера на cortex-m), ccm/tcm, внешнем sram, внешнем sdram.
Даже не говоря по то что все развлечения в статье очень синтетические и даже не заикаются про latency. Что в разговоре про real-time, где актуально считать такты, но оперерировать не столько throughput, сколько предсказуемым временем реакции на событие.
numeric Автор
29.11.2021 08:58Проектирование промышленных программ исключает обстоятельства, характеризуемые словосочетаниями "потом появляется", "вдруг" и т.п., ибо такие программы сопровождаются технической документацией, содержащей обязательный раздел "Технические условия эксплуатации", обоснованные в ТЭП ещё на этапе планирования разработки до создания исходного текста программы.
Время реакции на событие, предсказуемость времени реакции зависит от времени выполнения критических участков кода, обслуживающих событие. Об этом статья.
Практика всегда опирается на результат с заданной степенью точности. Достижение результата с заданной степенью точности всегда удовлетворяет практиков без оглядки на способ получения такого результата.
Приоритет способа в ущерб результату - это область теоретиков и учеников, допускающая непредвиденные обстоятельства типа "потом появляется", "вдруг" и т.д.При всём уважении к теоретикам, здесь о практике.
rus084
Какой компилятор вы использовали и с какими опциями оптимизации?
Можно увидеть целиком исходники на проект где вы запускали измерения?
Не совсем понятно почему операции с int16 должны быть дольше чем с int32. и почему вычитание и сложение выполняются за разное время
numeric Автор
CubeIDE, gcc (-std=gnu11, --specs=nano.specs -mfpu=fpv4-sp-d16 -mfloat-abi=hard -mthumb - Os)
На операциях int16 применяются дополнительные команды на преобразование полуслова 16bit в слово в 32. Процессор не может оперировать половиной регистра.
Два операнда по 16bit - две дополнительные команды ассемблера при загрузке данных из памяти в 32bit регистры процессора, плюс ещё одна дополнительная команда при выгрузке результата из регистра процессора в память.
Итого на три такта больше, чем сложение целых в формате 32bit, где всего четыре команды: две загрузки, одно сложение и одна выгрузка:
08003146: ldr r2, [r7, #12]
08003148: ldr r3, [r7, #8]
0800314a: add r3, r2
0800314c: str r3, [r7, #4]
Применяемая точность измерения сверхмалых интервалов +/- 1 такт, поэтому есть расхождения в 1-2 такта времени срабатывания int16 и int32, однако это не существенно для изложенной темы.
fougasse
Какой gcc? Почему -Os, если у вас целью является скорость?
Исходники тестируемого можно увидеть, или у вас чистейшая синтетика, когда вы 16 битами умудряетесь запутать оптимизатор и неоптимально сгенерировать бинарник?
Если пример реальный и разница в 2-4 такта настолько заметна — интересно хоть в двух словах узнать, что происходит, возможно проблему нужно решать не с разрядностью данных.
numeric Автор
Ответы в порядке поступления вопросов.
Компилятор:
gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.12)
Цель - не скорость.
Цель - повышение утилизации, интенсивности работы бюджетного MCU.
Оптимизация по размеру (-Os) выбрана на основании требований к памяти - 32Кб (64К крайний предел).
Что происходит - дополнительные издержки на преобразование полуслов в слова и обратно на операциях с данными, менее слова.
Оптимизатор запутать нельзя, т.к. на примитивных операциях он отдыхает. Да, и трудно представить, как оптимизировать формулу:
A = B + C, где все слагаемые int ?
Казалось бы ...
Отключение оптимизации (-O0) ухудшило арифметику на int16:
Причина замедления арифметики int16 - издержки на выравнивание границ и слов.
Формула на "С":
A = B + C
все переменные int16
Соответствующий машинный код:
Формула на "С":
A = B + C,
Все переменные int32
Соответствующий машинный код:
Как говорится, найдите 10 отличий. :-)
К вопросу о доверии к методу измерения.
Ассемблерные команды семейства Thumb2 выполняется, в основном, за 1 такт.
Если посчитать строчки ассемблера, то их число совпадёт с результатом соответствующих измерений в тактах из таблице выше по тексту.
VelocidadAbsurda
А исходники можно увидеть? А то бессмысленные последовательности из sxth-uxth наводят на мысль о каких-то странных преобразованиях типов.
Поигрался вот с разными версиями arm gcc - все упорно выдают то, что ниже выдал сам из головы.
lamerok
Да все верно компилятор делает. В соответствии со стандартом.
rus084
в случае если исходные операнды и результат имеют единственный тип - int16, инструкции sxth и uxth не влияют на итоговый результат и поэтому лишены смысла. Компилятор генерирует какой-то слишком не оптимальный код
fougasse
Внезапно, отключение оптимизации замедлило выполение.
Как отдыхает оптимизатор и почему? Вопрос зачем он делает неоптимально.
Реального кода нет, есть непонятно какие куски, с очевидно неоптимальным набором инструкций.
Похоже, что у вас где-то в погоне за «утилизацией» в макросах/коде творится страшное и компилятор не может понять, что от него хотят.
numeric Автор
Это нормально.
Забавно иначе, включение оптимизации замедляет работу алгоритма из 7-и строчек. :-)
Такой случай в примере для FPU x86 здесь, включая исходник:
https://habr.com/ru/post/562572/
Только, что току с того исходника? Зафиксировали факт. Двигаемся далее.
Разработчики компиляторов - они то же программисты, а это значит, что в их работе то же бывают косяки. :-)
Компилятор не должен "понимать". Компилятор обязан просто переводить алгоритмы программиста в машинный код, ибо программисту виднее, что он хочет посчитать и как. (imho)
В противном случае, имеем то, что имеем.
Вместо обсуждения прикладной задачи по планированию и учёту сверхмалых интервалов времени (вводная статья), профи с уважаемого форума тратят драгоценное внимание на косяки и тараканы в gcc - плотники до блеска натирают молотки вместо забивания гвоздей. :-)
С другой стороны, замусорить машинный код командами, "съёдающими" время без искажения задачи, в режиме -O0, и демонстрировать удивительную производительность с оптимизацией, - отличный маркетинговый ход! Троекратное "у-ра" маркетологам gcc. /* сарказм */ :-)
Но, я за программистов. :-)
lamerok
У компилятора все правильно.
Эта фишка называется integral promotion. И без оптимизации компилятор все делает, по стандарту
VelocidadAbsurda
Сама инструкция ldrh всегда осуществляет integral promotion (на этой архитектуре невозможна запись в половину регистра), только в случае знакового типа нужна её знаковая версия (ldrsh). Но даже если с -O0 компилятор принципиально разделяет чтение памяти и расширение типа, откуда две инструкции расширения (sxth, затем uxth)? В вашей цитате говорится "или к int или к unsigned int", но не "к int, затем к unsigned int", да и для исходного int16 должна работать первая половина жирного текста, т.е. приводить должны к int. Однако складываются почему-то результаты uxth.
VelocidadAbsurda
Покажите пример с int16. Совершенно непонятно откуда там лишние операции. Для работы с памятью есть инструкции и для halfword и для byte, вот навскидку сложение двух int16 с тем же кол-вом инструкций:
numeric Автор
Дизассемблер машинного кода без оптимизации (-O0) для int16 и int32, версия компилятора здесь:
https://habr.com/ru/post/591925/comments/#comment_23756785
Глядя на дизассемблер понятно, откуда набегают лишние такты.
Код на "C" для 16bit (сложение) выглядит так:
Код на "C" для 32bit (сложение) выглядит похоже:
Участки кода для других арифметических операторов выглядят идентично, за исключением арифметического оператора в строке #7.
fougasse
Так, а смысл в -О0 смотреть машкод?
Что там за кулисами RAND_NUMBER_x? Вдруг там у вас макрос функции дёргает или ещё что-то страшное творит? Что там с переполнениями?
numeric Автор
Да нет там ничего "военного":
Целочисленные переполнения - с этим тоже всё хорошо - проверенная временем практика непрерывной индексации циклических процессов.
У сборки -O0 cмысл в том, чтобы сузить зону поиска трабла, через исключение влияния оптимизатора кода.
Кстати, а для какого микроконтроллера Вы генерируете проверочный код?
lamerok
У компилятора все правильно.
Эта фишка называется integral promotion. И без оптимизации компилятор все делает, по стандарту