ARM Cortex M* — сколько «весит» примитив? +3



Видимые преимущества языка "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):

  1. rus084
    /#23756339 / +4

    Какой компилятор вы использовали и с какими опциями оптимизации?
    Можно увидеть целиком исходники на проект где вы запускали измерения?

    Не совсем понятно почему операции с int16 должны быть дольше чем с int32. и почему вычитание и сложение выполняются за разное время

    • numeric
      /#23756513 / +1

      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
        /#23756569 / +5

        Какой gcc? Почему -Os, если у вас целью является скорость?

        Исходники тестируемого можно увидеть, или у вас чистейшая синтетика, когда вы 16 битами умудряетесь запутать оптимизатор и неоптимально сгенерировать бинарник?

        Если пример реальный и разница в 2-4 такта настолько заметна — интересно хоть в двух словах узнать, что происходит, возможно проблему нужно решать не с разрядностью данных.

        • numeric
          /#23756785 / +1

          Ответы в порядке поступления вопросов.


          Компилятор:
          gcc version 5.4.0 20160609 (Ubuntu 5.4.0-6ubuntu1~16.04.12)

          Цель - не скорость.
          Цель - повышение утилизации, интенсивности работы бюджетного MCU.

          Оптимизация по размеру (-Os) выбрана на основании требований к памяти - 32Кб (64К крайний предел).

          Что происходит - дополнительные издержки на преобразование полуслов в слова и обратно на операциях с данными, менее слова.

          Оптимизатор запутать нельзя, т.к. на примитивных операциях он отдыхает. Да, и трудно представить, как оптимизировать формулу:
          A = B + C, где все слагаемые int ?

          Казалось бы ...
          Отключение оптимизации (-O0) ухудшило арифметику на int16:

          +=========================================
          +-------------- #  1 ---------------------
          +-- RELEASE at 15:51:09
          +-- CPU:48 MHz, STM32 ARM Cortex M4
          +-----------------------------------------
          +-- cpu time of simple int16 "C"
          fn:    i3 = i1 + i2,	cc:   11, us:    0
          fn:    i3 = i1 - i2,	cc:   11, us:    0
          fn:    i3 = i1 * i2,	cc:   11, us:    0
          fn:    i3 = i1 / i2,	cc:   13, us:    0
          fn:    i3 = i1 % i2,	cc:   14, us:    0
          --- is:715826417 ---
          +-----------------------------------------
          +-- cpu time of simple int32 "C"
          fn:    l3 = l1 + l2,	cc:    4, 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:   10, us:    0
          --- ls:223077021 ---

          Причина замедления арифметики int16 - издержки на выравнивание границ и слов.

          Формула на "С":
          A = B + C
          все переменные int16

          Соответствующий машинный код:

          08002a2a:   ldrh    r3, [r7, #6]
          08002a2c:   sxth    r3, r3
          08002a2e:   uxth    r2, r3
          08002a30:   ldrh    r3, [r7, #4]
          08002a32:   sxth    r3, r3
          08002a34:   uxth    r3, r3
          08002a36:   add     r3, r2
          08002a38:   uxth    r3, r3
          08002a3a:   sxth    r3, r3
          08002a3c:   strh    r3, [r7, #2]

          Формула на "С":
          A = B + C,
          Все переменные int32

          Соответствующий машинный код:

          08003146: ldr r2, [r7, #12]
          08003148: ldr r3, [r7, #8]
          0800314a: add r3, r2
          0800314c: str r3, [r7, #4]

          Как говорится, найдите 10 отличий. :-)

          К вопросу о доверии к методу измерения.

          Ассемблерные команды семейства Thumb2 выполняется, в основном, за 1 такт.

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

          • VelocidadAbsurda
            /#23757207

            А исходники можно увидеть? А то бессмысленные последовательности из sxth-uxth наводят на мысль о каких-то странных преобразованиях типов.

            Поигрался вот с разными версиями arm gcc - все упорно выдают то, что ниже выдал сам из головы.

            • lamerok
              /#23761309

              Да все верно компилятор делает. В соответствии со стандартом.

          • rus084
            /#23757225 / +1

            в случае если исходные операнды и результат имеют единственный тип - int16, инструкции sxth и uxth не влияют на итоговый результат и поэтому лишены смысла. Компилятор генерирует какой-то слишком не оптимальный код

          • fougasse
            /#23757451 / +1

            Внезапно, отключение оптимизации замедлило выполение.

            Как отдыхает оптимизатор и почему? Вопрос зачем он делает неоптимально.

            Реального кода нет, есть непонятно какие куски, с очевидно неоптимальным набором инструкций.

            Похоже, что у вас где-то в погоне за «утилизацией» в макросах/коде творится страшное и компилятор не может понять, что от него хотят.

            • numeric
              /#23758799

              Внезапно, отключение оптимизации замедлило выполение.

              Это нормально.

              Забавно иначе, включение оптимизации замедляет работу алгоритма из 7-и строчек. :-)

              Такой случай в примере для FPU x86 здесь, включая исходник:
              https://habr.com/ru/post/562572/

              Только, что току с того исходника? Зафиксировали факт. Двигаемся далее.

              Разработчики компиляторов - они то же программисты, а это значит, что в их работе то же бывают косяки. :-)

              компилятор не может понять, что от него хотят

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

              В противном случае, имеем то, что имеем.

              Вместо обсуждения прикладной задачи по планированию и учёту сверхмалых интервалов времени (вводная статья), профи с уважаемого форума тратят драгоценное внимание на косяки и тараканы в gcc - плотники до блеска натирают молотки вместо забивания гвоздей. :-)

              С другой стороны, замусорить машинный код командами, "съёдающими" время без искажения задачи, в режиме -O0, и демонстрировать удивительную производительность с оптимизацией, - отличный маркетинговый ход! Троекратное "у-ра" маркетологам gcc. /* сарказм */ :-)

              Но, я за программистов. :-)

            • lamerok
              /#23761301

              У компилятора все правильно.

              Эта фишка называется integral promotion. И без оптимизации компилятор все делает, по стандарту

              A.6.1 Integral Promotion

              A character, a short integer, or an integer bit-field, all either signed or not, or an object of enumeration type, may be used in an expression wherever an integer may be used. If an int can represent all the values of the original type, then the value is converted to int; otherwise the value is converted to unsigned int. This process is called integral promotion.

              • VelocidadAbsurda
                /#23765967

                Сама инструкция ldrh всегда осуществляет integral promotion (на этой архитектуре невозможна запись в половину регистра), только в случае знакового типа нужна её знаковая версия (ldrsh). Но даже если с -O0 компилятор принципиально разделяет чтение памяти и расширение типа, откуда две инструкции расширения (sxth, затем uxth)? В вашей цитате говорится "или к int или к unsigned int", но не "к int, затем к unsigned int", да и для исходного int16 должна работать первая половина жирного текста, т.е. приводить должны к int. Однако складываются почему-то результаты uxth.

      • VelocidadAbsurda
        /#23756679 / +3

        Покажите пример с int16. Совершенно непонятно откуда там лишние операции. Для работы с памятью есть инструкции и для halfword и для byte, вот навскидку сложение двух int16 с тем же кол-вом инструкций:

        ldrsh r0, [r2]
        ldrsh r1, [r3]
        add r0, r1
        strh r0, [r2]

        • numeric
          /#23757343 / +1

          Дизассемблер машинного кода без оптимизации (-O0) для int16 и int32, версия компилятора здесь:

          https://habr.com/ru/post/591925/comments/#comment_23756785

          Глядя на дизассемблер понятно, откуда набегают лишние такты.

          Код на "C" для 16bit (сложение) выглядит так:

             	__IO int16_t	i1, i2, i3, is;
          
          	i1 = RAND_NUMBER_16;
          	i2 = RAND_NUMBER_16;
          	i3 = 0;
          
          	i3 = i1 + i2;
          
          	is += i3;

          Код на "C" для 32bit (сложение) выглядит похоже:

          	__IO int32_t	i1, i2, i3, is;
          
          	i1 = RAND_NUMBER_32;
          	i2 = RAND_NUMBER_32;
          	i3 = 0;
          
          	i3 = i1 + i2;
          
          	is += i3;

          Участки кода для других арифметических операторов выглядят идентично, за исключением арифметического оператора в строке #7.

          • fougasse
            /#23757435

            Так, а смысл в -О0 смотреть машкод?

            Что там за кулисами RAND_NUMBER_x? Вдруг там у вас макрос функции дёргает или ещё что-то страшное творит? Что там с переполнениями?

            • numeric
              /#23758289

              Да нет там ничего "военного":

              #define RAND_NUMBER_16 (int16_t)((rand() - RAND_MAX / 2) % INT16_MAX )

              Целочисленные переполнения - с этим тоже всё хорошо - проверенная временем практика непрерывной индексации циклических процессов.

              У сборки -O0 cмысл в том, чтобы сузить зону поиска трабла, через исключение влияния оптимизатора кода.

              Кстати, а для какого микроконтроллера Вы генерируете проверочный код?

        • lamerok
          /#23761303

          У компилятора все правильно.

          Эта фишка называется integral promotion. И без оптимизации компилятор все делает, по стандарту

          A.6.1 Integral Promotion

          A character, a short integer, or an integer bit-field, all either signed or not, or an object of enumeration type, may be used in an expression wherever an integer may be used. If an int can represent all the values of the original type, then the value is converted to int; otherwise the value is converted to unsigned int. This process is called integral promotion.

  2. fougasse
    /#23756403 / +5

    Какой компилятор, какие опции, где хотя бы дизасм бинарника?

    По поводу неосторожного использования FP — вопросов ни у кого нет, а вот про разницу int16 и int32 уже закрадываются сомнения в правильности измерений(вы сами упоминаете выравнивания и прочее) и тестовой инфраструктуры.

    • numeric
      /#23756601

      про разницу int16 и int32 уже закрадываются сомнения в правильности измерений.

      Замедление скорости вычислений для int16 обосновано в ответе на комментарий выше. Там же ассемблерный код для оператора:
      L3 = L2 + L1,
      где все переменные с типом int32.

      Для той же формулы с переменными int16, в машинном коде появятся дополнительные команды, преобразующие машинное полу-слово в слово и обратно.

      • fougasse
        /#23756717

        Уже ответили, что есть асм-инструкции для полуслов.

        Нужно смотреть машкоды и конкретный тулчейн с его опциями. Ну и «синтетичность» задачи.

        Пока выглядит очень странно.

        • numeric
          /#23756875

          Да, нормально оно выглядит. :-)

          В ответе выше дизассемблер машинного кода, полученного штатным gcc из исходника на "C" с опцией -O0.

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

          • fougasse
            /#23760619

            Зачем смотреть на -О0, вы можете объяснить?

            • numeric
              /#23761053

              Для оценки соответствия машинного кода и кода на "C"; для снижения энтропии системы сборки через исключение модуля искажающего первичную алгоритмическую основу.

              Дополнительная аргументация здесь:
              https://habr.com/ru/post/591925/comments/#comment_23758799

  3. Afterk
    /#23756533 / +4

    double в 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
      /#23756615

      О том, что в премиальном сегменте "железа" больше возможностей - спору нет.

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

  4. beeruser
    /#23758101 / +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
      /#23758313 / -1

      Ничего, что команды сложения, вычитания и умножения на M0 выполняются за одинаковое время - 1 такт?

      Согласен. В теории Вы правы и документация верна.

      На практике простейшая операция выполняется минимум за 4 такта: два такта на загрузку двух операндов из памяти в регистры, один такт, как Вы правильно заметили, уходит на саму примитивную операцию, и один такт на возврат результата из регистра в память.

      В эти 4 такта может легко вклинится обработчик прерывания, если ему приспичит. Есть множество других, объективных причин, влияющих на время выполнения простейших операций.

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

      • fougasse
        /#23760191

        И какая разница? У вас для 32 бит из памяти в регистры и обратно нет операций загрузки? Между ними не «вклинивается обоаботчик прерываний, если ему приспичит»?

        Бороться и учитыватт можно например алгоритмически, можно запрещая прерывания, можно ещё множеством способов если нужна гарантированная производительность.

        Пока же вы меряете непонятно что, показывая какие-то обрывки ассемблера, которые никто воспроизвести не может(включая здравый смысл).

        • numeric
          /#23760409

          Вы правы, есть загрузка из памяти в регистры, и прерывания вклиниваются когда посчитают нужным.

          В статье о поиске способа простого планирования и учёта времени работы ответственных участков кода оставаясь в парадигме языка "C".

          Говоря о планировании, подразумеваем не обязательство, а цель, допускающую отклонение в пределах установленного допуска.

          Это значит - без запретов прерываний, без порочного ожидания "delay()" и т.п.

          Предположительное начальное приближения к решению задачи - подсчёт числа простейших арифметических операторов на языке "С" на контрольном участке кода.

          Под простейшим оператором понимаем конструкцию типа: A = B + C, где A, B и C - переменные в RAM.

          Время срабатывания простейших арифметических операторов "C" поддаётся измерению с удовлетворительно точностью (+/- 1 такт) без понижения абстракции до уровня ассемблера и машинных команд, в контексте синтаксиса "С".

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

          • fougasse
            /#23761221

            Т.е. чистая синтетика, а кода так и нет.

            • numeric
              /#23761529

              Что такое "чистая синтетика"? - сомневаюсь, что правильно понимаю этот термин.

  5. grossws
    /#23760859 / +1

    А потом выясняется что где-то ldr был из внешнего sdram и здравствуй добрая сотня тактов. А ваша переменная в памяти могла оказаться в l1d (который может присутствовать в конкретной имплементации контроллера на cortex-m), ccm/tcm, внешнем sram, внешнем sdram.

    Даже не говоря по то что все развлечения в статье очень синтетические и даже не заикаются про latency. Что в разговоре про real-time, где актуально считать такты, но оперерировать не столько throughput, сколько предсказуемым временем реакции на событие.

    • numeric
      /#23762207

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

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

      Практика всегда опирается на результат с заданной степенью точности. Достижение результата с заданной степенью точности всегда удовлетворяет практиков без оглядки на способ получения такого результата.

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

      При всём уважении к теоретикам, здесь о практике.