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

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

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 реализует ХЗ …

1 лайк

И вопрос сколько времени крутиться цикл если из него вообще всё выбросить и оставть один волатильный 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 в команде не может быть указан полный адрес. Может быть указано только смещение, имеющее меньшую разрядность. Т.е. время выполнения фрагмента может зависеть от расположения частей кода, от расстояния между частями кода, а не только от количества выполняемых инструкций. И то же самое касается объема и расположения фрагментов данных.

1 лайк

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

1 лайк

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

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

200 * 350 / 4096 = 17.09

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

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

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

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