ESP32 の命令実行サイクル数 (2)

「esp-idf」環境で作成されるアプリケーションでは、通常の C プログラムでの main() 関数に相当するものは app_main() 関数となっています。
FreeRTOS ベースで構成されており、システム側での各種の「お膳立て」が終了した後に app_main() 関数が呼ばれます。
「普通」にユーザ・アプリケーションを app_main() 関数に記述すると、それはデュアルコアの CPU1 側 (「アプリケーション CPU」の意で「APP CPU」と呼ばれる) を 100 % 占有するタスクとして実行されます。
もちろん、FreeRTOS 等の API を利用すれば、APP CPU 側に複数のタスクを走らせたり、CPU0 側 (「プロトコル CPU」の意で「PRO CPU」と呼ばれる) にタスクを走らせることもできます。
その「お膳立て」のひとつとして「High Resolution Timer」と称する、ブート時からの経過時間を μs 単位でカウントするタイマーが起動され、下記のような定義の関数、

int64_t esp_timer_get_time();

で現在のカウント値を読み取ることができます。
CPU クロックが 240 MHz のとき、ループを 2 億 4000 万回実行させ経過時間を計測すれば、1 秒 = 1 サイクルという対応関係からルーブの実行サイクル数を計算で求めることができます。
このままだとサイクル数が大きいときに時間がかかり過ぎるので、0.1 秒 = 1 サイクルの対応として、ループ回数を 2400 万回としました。
この 0.1 秒単位の計測では、μs 単位のタイマ・カウントで十分な精度を得ることができます。
サイクル数計測のプログラム例を下に示します。

#include <stdio.h>
#include <stdlib.h>
#include "esp_system.h"
#include "esp_timer.h"

#define n_iter (CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ * 100000L)  

void app_main()
{
  uint32_t a0 = rand();
  uint32_t a1 = rand();
  int64_t  prev_time;
  int64_t  curr_time = 0;
  int32_t  time32;
  int      i;
  
  prev_time = esp_timer_get_time();
  for (i = 0; i < n_iter; i++) {
    a0 ^= a1;
  } // for (i = 0; ...
  curr_time = esp_timer_get_time();
  time32 = (curr_time-prev_time);
  printf("cycle = %g, %d\n", (time32 / 100000.0), a0);
} // void app_main()

「CONFIG_ESP32_DEFAULT_CPU_FREQ_MHZ」は「make menuconfig」で設定する CPU クロック周波数の値で、MHz 単位で表現したものです。 (240 Hz なら「240」、160 MHz なら「160」)
演算命令のオペランドとなる変数 a0, a1 に初期値として乱数を代入しているのは、最適化により、コンパイル時の事前計算で結果を計算されて for  ループ自体が除去されることを防ぐためです。
ループ内の計算も、単純な加算だと (乱数値を代入された) a0, a1 の値から事前計算されてしまうので、排他的論理和を使用しています。
排他的論理和でも、ループ回数の偶奇に応じて、2 種の結果しか生じませんが、それはコンパイラの最適化の対象にはならないようです。
計算結果の a0 の値も printf() 関数で表示していますが、別に値が見たいわけではなく、変数の値を使用しないと最適化で演算そのものが削除されてしまうからです。
上のプログラムのコンパイル結果を逆アセンブルしたものを下に示します。

00000000 <app_main>:
  
  . . . . . <中略> . . . . .
  
  1e:   028876          loop    a8, 24 
  21:   302240          xor     a2, a2, a4
  
  24:   . . . . .

for ループが loop 命令にコンパイルされているので「ゼロ・オーバーヘッド」となり、ループ内の「xor」命令の実行サイクル数だけが計測されることになります。
結果は、「cycle = 1.00063」で、xor 命令は 1 サイクルで実行されていることが分かります。
以降は、最適化を逃れるための記述は省略し、for ループ周りの記述のみを示します。
単精度実数演算の場合の例を下に示します。

  float a, b;
  
  for (i = 0; i < n_iter; i++) {
    a *= b;
  } // for

コンパイル結果を逆アセンブルしたものは、

  69:   028876          loop    a8, 6f
  6c:   2a0010          mul.s   f0, f0, f1
  
  6f:   . . . . .

となります。
実行結果は mul.s 命令ひとつだけなのに「cycle = 4.00194」となりました。 これは、同一の浮動小数レジスタに連続して書き込もうとするとパイプライン・ストールが生じるためと思われます。
いろいろ試してみた結果、演算命令で値が書き換わった浮動小数レジスタを別の演算のオペランドとして使ったり、再度値を書き換えるためには、最初の命令を含めて 4 サイクル以上経過している必要があるようです。
これは、当該レジスタ以外の浮動小数レジスタに対するオペレーションならば浮動小数点演算を並べてもストールしないようです。 
そのような例を下に示します。

  float a, b, c, d;
  float e, f, g, h;
  
  for (i = 0; i < n_iter; i++) {
    a *= b;
    c += d;	
    e -= f;
    g -= h;
  } // for

コンパイル結果を逆アセンブルしたものは、

  8d:   0b8876          loop    a8, 9c
  90:   2a2240          mul.s   f2, f2, f4
  93:   0a3370          add.s   f3, f3, f7
  96:   1a0060          sub.s   f0, f0, f6
  99:   1a1150          sub.s   f1, f1, f5
  
  9c:   . . . . .

となります。
実行結果は「cycle = 4.00198」となりました。
次回は、for ループがハードウェア・ループ命令にコンパイルされない場合を取り上げます。