О времени исполнения циклов

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

Code
#define BUF_SIZE 8192
#define pin 11
uint32_t buffer1[BUF_SIZE] = {0};
uint32_t buffer2[BUF_SIZE] = {0};

void setup() {
  pinMode(pin, OUTPUT);
  for (auto i = 0; i < BUF_SIZE; i++) {
    buffer1[i] = (random(255) << 16) | (random(255) << 8) | random(255);
    //buffer1[i] |= (i % 2) << 24;
  }
}

// transpose 64 x 64 bit matrix
// Based on: https://web.archive.org/web/20190108225554/http://www.hackersdelight.org/hdcodetxt/transpose8.c.txt
void matrix_transform(uint32_t* buffer1, uint32_t* buffer2) {
  uint8_t* B = (uint8_t*)buffer2;
  const int n = BUF_SIZE / 2;
  for (auto k = 0; k < BUF_SIZE / 2 ; k++) {
    uint32_t A = buffer1[k];
    uint32_t A1 = buffer1[k + BUF_SIZE / 2];


    // Copy input values into x and y.
    // Test upper nible
    if ((A >> 24) & (A1 >> 24))  {

      unsigned x, y, t;
      x = A;
      y = A1 << 8;

      t = (x ^ (x >> 7)) & 0x00AA00AA;  x = x ^ t ^ (t << 7);
      t = (y ^ (y >> 7)) & 0x00AA00AA;  y = y ^ t ^ (t << 7);

      t = (x ^ (x >> 14)) & 0x0000CCCC;  x = x ^ t ^ (t << 14);
      t = (y ^ (y >> 14)) & 0x0000CCCC;  y = y ^ t ^ (t << 14);

      t = (x & 0xF0F0F0F0) | ((y >> 4) & 0x0F0F0F0F);
      y = ((x << 4) & 0xF0F0F0F0) | (y & 0x0F0F0F0F);
      x = t;

      // with next two lines the loop duration 350us
      B[0] = x>>24;    B[n] = x>>16;    B[2*n] = x>>8;  B[3*n] = x;
      B[4*n] = y>>24;  B[5*n] = y>>16;  B[6*n] = y>>8;  B[7*n] = y;

      // but with these lines = 310us
      //*B = x;
      //*(++B) = y;
    }
    B++;
  }
}

void loop() {
  gpio_put(pin, HIGH);
  matrix_transform(buffer1, buffer2);
  gpio_put(pin, LOW);
  delay(25);

}

В чем суть - в коде есть буфер данных (8К 32битных значений) и есть цикл, который берет по два значения (uint32) из входного буфера, применяет к ним алгоритм и кладет в выходной буфер.
В какой-то момент, анализируя скорость исполнения, я захотел понять, сколько же времени занимает сама обработка, а сколько - накладные расходы на цикл и на копирование данных. Поэтому я вставил в цикл условие строка 26, которое всегда ложно. То есть теперь программа вообще не заходит в блок обработки (стр 26-49) и просто крутит цикл впустую. Время исполнения составило 350мкс, что на мой взгляд как-то много. В цикле всего 4096 практически пустых итераций, что при частоте процессора 150 МГц составляет примерно 17 контроллерных тиков на итерацию
Вопрос 1 - Можно ли это время как-то уменьшить? Может у меня цикл организован не оптимально?

Но погодите, вопрос 2 интереснее.
Если в этом тесте с пустыми итерациями изменить код внутри блока, в который программа не заходит - время исполнения цикла меняется! Вот этого я вообще не понимаю. Конкретно, если строчки 43-44 заменить на строчки 47-48, полный цикл станет занимать 310 мкс вместо 350.
Как так? Ведь строки НЕ ИСПОЛНЯЮТСЯ!
Надеюсь наши гуру легко обьяснят, в чем тут дело.

Технические детали
плата Расбери Пико 2040, FPU 150MHz, аддон Филхофера 5.4.3
Пробовал разные режимы оптимизации - время меняется, общий принцип нет.

На всякий случай, чтобы проверить что программа действительно не заходит в блок строк 26-49 - добавлял внутрь блока вывод импульса на внешний пин и смотрел его анализатором. Не мигает.

Пока нет ответов от гуру, я вставлю 5коп. дилетанта.
Если Вы время выполнения измеряете осциллографом, то, возможно дело в том, что частота I/O значительно ниже частоты ядра процессора.
Поэтому есть большая задержка в ожидании начала выполнения записи в порт.

Надо листинг глянуть … как он там сдвиг на 24 реализует ХЗ …

И вопрос сколько времени крутиться цикл если из него вообще всё выбросить и оставть один волатильный NOP?

Вы какой порт имеете в виду - GPIO ? Да не, не думаю. Даже самые медленные пины на МК обеспечивают выходные частоты 10-15 МГц, то есть задержки составляют доли микросекунд.
А в моем тесте, напомню, результат на три порядка больше

вообще должно минимум получиться 14.

  1. инкремент счетчика -1
  2. условный переход -3
  3. чтение из памяти -2
  4. чтение из памяти -2
  5. сдвиг -1
  6. сдвиг -1
  7. И-1
  8. условный переход -3
    итого 14 тактов.
    что-то еще видимо на 3 такта есть. Перепиши на любимом языке Алегира, получишь 14 тактов. :wink:
    Я думаю что два сдвига и “И” собрались не в 3 такта, а чуть сложнее, но это ве равно не принципиально.
    ====

разница ровно в один такт в цикле. Просто проверка условия по другому собралась оптимизатором… условный переход тратит один такт, если его нет, и три - если он случился. Поменяй проверку с учетом использования и будет чуть иначе выполняться… оптимизатор собрал код чуть иначе и поставил другую проверку. Измерения примерно это и дали. 1-2 такта разницы на цикл.

А скомпилируйте ваш скетч с -S -g, чтобы ассемблерный исходник получить. Для начала.

С лишними инструкциями и без. Там понятнее немного станет, что происходит.

Обычно, если добавление dead-code меняет тайминги, то это с большой вероятностью - CPU cache ИЛИ оптимизатор. Надобно в ассемблерный аутпут глянуть.

я бы для надежности туда воткнул бы abort(). Кто их знает, эти светодиоды..

Это пока гипотЭза. Вставьте туда abort(), чтобы уж наверняка. Я не знаю, что делает этот алгоритм, но вот в строке 26 там маска проверяется или && все же должно быть?

Да, гипотеза МММ верна, в блок мы не заходим.

@MMM, а можешь в свой код вставить точки замера времени? что конкретно ты измеряешь. А я посмотрю на хост-машине.

Вот верся для Linux вашего кода, слегка переделанного (чтобы компилировался Си-компилятором), с которым можно играться. Оно компилируется под Linux и под Windows/Cygwin

// Затычка, чтобы компилировалось в posix-системах
// --------------------------------------------------
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdbool.h>
#include <unistd.h>

#define HIGH 1
#define LOW 0
#define OUTPUT 1
#define INPUT 0
#ifdef __cplusplus
#else
#  define auto int
#endif

#define  pinMode(_X, _Y) do { asm ("nop" ::: "memory"); } while(0)
#define  gpio_put(_X, _Y) do { asm ("nop" ::: "memory"); } while(0)
#define  delay(_X) usleep((_X) * 1000)
// --------------------------------------------------

#define BUF_SIZE 8192
#define pin 11
uint32_t buffer1[BUF_SIZE] = {0};
uint32_t buffer2[BUF_SIZE] = {0};

void setup() {
  pinMode(pin, OUTPUT);
  for (auto i = 0; i < BUF_SIZE; i++) {
    buffer1[i] = ((random() & 255) << 16) | ((random() & 255) << 8) | (random() & 255);
    //buffer1[i] |= (i % 2) << 24;
  }
}

// transpose 64 x 64 bit matrix
// Based on: https://web.archive.org/web/20190108225554/http://www.hackersdelight.org/hdcodetxt/transpose8.c.txt
void matrix_transform(uint32_t* buffer1, uint32_t* buffer2) {
  uint8_t* B = (uint8_t*)buffer2;
  const int n = BUF_SIZE / 2;
  for (auto k = 0; k < BUF_SIZE / 2 ; k++) {
    uint32_t A = buffer1[k];
    uint32_t A1 = buffer1[k + BUF_SIZE / 2];


    // Copy input values into x and y.
    // Test upper nible
    if ((A >> 24) & (A1 >> 24))  {

      unsigned x, y, t;
      x = A;
      y = A1 << 8;

      t = (x ^ (x >> 7)) & 0x00AA00AA;  x = x ^ t ^ (t << 7);
      t = (y ^ (y >> 7)) & 0x00AA00AA;  y = y ^ t ^ (t << 7);

      t = (x ^ (x >> 14)) & 0x0000CCCC;  x = x ^ t ^ (t << 14);
      t = (y ^ (y >> 14)) & 0x0000CCCC;  y = y ^ t ^ (t << 14);

      t = (x & 0xF0F0F0F0) | ((y >> 4) & 0x0F0F0F0F);
      y = ((x << 4) & 0xF0F0F0F0) | (y & 0x0F0F0F0F);
      x = t;

      // with next two lines the loop duration 350us
      B[0] = x>>24;    B[n] = x>>16;    B[2*n] = x>>8;  B[3*n] = x;
      B[4*n] = y>>24;  B[5*n] = y>>16;  B[6*n] = y>>8;  B[7*n] = y;

      // but with these lines = 310us
      //*B = x;
      //*(++B) = y;
    }
    B++;
  }
}

void loop() {
  gpio_put(pin, HIGH);
  matrix_transform(buffer1, buffer2);
  gpio_put(pin, LOW);
  delay(25);
}


int main() {
  setup();
  while(true)
    loop();
  return 0;
}

Вот смотрите: ваш блок кода, который вы замеряете, тот который через сдвиги, скомпилированный под Intel:

  movl  %ecx, %r11d
  movb  %ch, 8192(%rax)
  shrl  $24, %r11d
  movb  %cl, 12288(%rax)
  movb  %r11b, (%rax)
  movl  %ecx, %r11d
  shrl  $16, %r11d
  movb  %dh, 24576(%rax)
  movb  %r11b, 4096(%rax)
  movl  %edx, %r11d
  shrl  $24, %r11d
  movb  %dl, 28672(%rax)
  movb  %r11b, 16384(%rax)
  movl  %edx, %r11d
  shrl  $16, %r11d
  movb  %r11b, 20480(%rax)

А вот кот от тех двух строчек, который закомментированы:

  movb  %cl, (%rax)
  movb  %dl, 1(%rax)

Это со включенной оптимизацией. С выключенной - картина похожая: код со сдвигами - 2 экрана, закоментированный код - полэкрана

Давайте, расставляйте точки замера свои, будем изучать :0)

PS:

Быстрый тест: закомментируйте тот блок, в который мы типа и так не заходим. Не улучшатся ли показатели? Если улучшатся, значит это такой вот слабенький кэш у вашего процессора :-/

На “пашолкатынах”? Ты прошёл.

Что-то у меня арифметика не сходится.
350 мкс - это 4096 проходов цикла, значит, один проход - 350/4096=0,085 мкс.
За одну мкс - 150 тактов процессора, значит, за 0,085 - 0,085*150=12,8 тактов.
Мне кажется, для кода, который содержит два чтения из памяти, два инкремента (счетчик цикла и В), два сдвига, логическую операцию, сравнение и два условных перехода, вполне разумное время.
Но, вообще-то, следовало бы выложить ассемблерный листинг, без него делать какие-то заключения о процессоре, который никогда не держал в руках, не продуктивно.

Вообще-то это норма.
Я, опять же, не знаю архитектуры процессора, но заметную роль играет выравнивание, то есть как именно будут расположены инструкции относительно границ в 4, 8, 16 байтов. Насколько я помню, ARM - не совсем RISC, там используются как 32-, так и 16-разрядные инструкции, значит, даже выравнивание на границу 4 байта может быть различным. Я не знаю, есть ли у контроллера кэш. Если есть, длина строки кэша тоже вносит с вою лепту.
Далее - переходы. В силу специфики RISC в команде не может быть указан полный адрес. Может быть указано только смещение, имеющее меньшую разрядность. Т.е. время выполнения фрагмента может зависеть от расположения частей кода, от расстояния между частями кода, а не только от количества выполняемых инструкций. И то же самое касается объема и расположения фрагментов данных.

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

О, сколько всего понаписали. Огромное спасибо откликнувшимся.

Прошу прощения, частота МК 200 МГц. Это я на двух разных контроллерах запускал и потому зарапортовался. Времена, приведенные в тексте - для 200 МГц.. Тогда арифметика верная

200 * 350 / 4096 = 17.09

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

Маска. Проверяется, что 24й бит обоих значений одновременно не ноль.
А он - ноль, если посмотреть на инициализацию значений в строчках 8-11

Все так понятно объяснили, что теперь ответ кажется очевидным.

@WladDrakula и @andriano , особенное спасибо