X86处理器可以通过CPUID 指令获取CPU 的基频(Base frequency),但嵌入式CPU 往往没有提供这样的指令,另一种朴素而有效的思路是:用高精度时钟计量N条指令执行时间来计算CPU 当前频率。这种方法在Linux 启动时也会用到,毕竟不是所有CPU 都支持CPUID 或类似指令。
简单而直观的例子
下面是一段简单而直观的例子,通过累加寄存器1000次(CPU 执行一次累加需要一个时钟周期)计量CPU 频率。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | #define INC(cnt) "inc %[cnt] \n" #define INC10(cnt) INC(cnt) INC(cnt) INC(cnt) INC(cnt) \ INC(cnt) INC(cnt) INC(cnt) INC(cnt) INC(cnt) INC(cnt) #define INC100(cnt) INC10(cnt) INC10(cnt) INC10(cnt) INC10(cnt) \ INC10(cnt) INC10(cnt) INC10(cnt) INC10(cnt) INC10(cnt) INC10(cnt) #define INC1K(cnt) INC100(cnt) INC100(cnt) INC100(cnt) INC100(cnt) \ INC100(cnt) INC100(cnt) INC100(cnt) INC100(cnt) INC100(cnt) INC100(cnt) /** * should compile the code with no optimization, gcc -O0 */ void measure0() { uint64_t start, end; int temp; // 高精度计时开始 tStart __ams( INC1K(temp) : [cnt] "+r" (temp)); // 高精度计时结束 tEnd printf ( "CPU frequency : %d Hz\n" , 1000/(tEnd-tStart)); //计算频率 } |
上面的被测代码汇编后代码如下(eax 寄存器缓存了栈上的temp 数值,并自增了1000 次):
1 2 3 4 5 6 7 | 0x000000000000202c <+47>: mov -0x24(%rbp),%eax 0x000000000000202f <+50>: inc %eax 0x0000000000002031 <+52>: inc %eax 0x0000000000002033 <+54>: inc %eax ... ... 0x00000000000xxxxx <+58>: inc %eax 0x00000000000xxxxx <+250>: mov %eax,-0x24(%rbp) |
注意:1 高精度时钟选择对结果的影响较大;2 增大测量的时钟周期越长,结果CPU 的频率更精确;3 不能添加编译优化选项。
该方法主要难度在于:精确编写运行期望CPU cycles的代码。
下面从反面来看看哪些示例会更多/更少地执行了期望CPU cycles:
错误1:访问非寄存器导致CPU stall
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | #define INC(dd) __asm("inc %[counter] \n" : [counter] "+r"(dd)); #define INC10(dd) INC(dd) INC(dd) INC(dd) INC(dd) INC(dd) \ INC(dd) INC(dd) INC(dd) INC(dd) INC(dd) #define INC100(dd) INC10(dd) INC10(dd) INC10(dd) INC10(dd) \ INC10(dd) INC10(dd) INC10(dd) INC10(dd) INC10(dd) INC10(dd) #define INC1K(dd) INC100(dd) INC100(dd) INC100(dd) INC100(dd) \ INC100(dd) INC100(dd) INC100(dd) INC100(dd) INC100(dd) INC100(dd) void measure1() { uint64_t start, end; int temp; // 高精度计时开始 tStart INC1K(temp); // 高精度计时结束 tEnd printf ( "CPU frequency : %d Hz\n" , 1000/(tEnd-tStart)); //计算频率 } |
原因分析:部分汇编的代码是这样的。
1 2 3 4 5 | 0x000000000000202c <+47>: mov -0x24(%rbp),%eax 0x000000000000202f <+50>: inc %eax 0x0000000000002031 <+52>: mov %eax,-0x24(%rbp) 0x0000000000002034 <+55>: mov -0x24(%rbp),%eax 0x0000000000002037 <+58>: inc %eax |
汇编后的代码没有被优化,CPU 每次都需要从Cache/Memory 中获取temp 数据并回写,导致不能在每个时钟周期都执行一条inc 指令。
错误2:被测代码指令级并行使得运行CPU cycles 小于期望
如果被测代码中自加的变量不止一个,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 | __asm volatile( "cyclemeasure2:\n" " dec %[counter] \n" " dec %[counter] \n" " dec %[counter] \n" " dec %[counter] \n" " dec %[counter2] \n" " dec %[counter2] \n" " dec %[counter2] \n" " dec %[counter2] \n" " jnz cyclemeasure2 \n" : /* read/write reg */ [counter] "+r"(cycles[0]), [counter2] "+r"(cycles[1]) ); |
而counter 和counter2 又没有数据依赖关系,那么那么在同一个CPU cycle 中同时被执行(超标量),花费的时钟周期略大于counter2 初始值,但远小于两倍counter2。从其汇编结果可以看出来,dec %eax 和dec %edx 可以在指令集并行。
1 2 3 4 5 6 7 8 9 10 11 | 0x00000000000013c0 <+69>: mov -0x10(%rbp),%edx 0x00000000000013c3 <+72>: mov -0xc(%rbp),%eax 0x00000000000013c6 <+75>: dec %edx 0x00000000000013c8 <+77>: dec %edx 0x00000000000013ca <+79>: dec %edx 0x00000000000013cc <+81>: dec %edx 0x00000000000013ce <+83>: dec %eax 0x00000000000013d0 <+85>: dec %eax 0x00000000000013d2 <+87>: dec %eax 0x00000000000013d4 <+89>: dec %eax 0x00000000000013d6 <+91>: jne 0x13c6 <measure2p+75> |
循环减小代码段长度
生成那么太长的被测代码段可能导致iCache miss 或缺页中断,影响测试结果,上面示例中给除了基于循环的测试代码
1 2 3 4 5 6 7 8 9 10 11 12 13 | int cycles = 65536; rdtsc(start); __asm volatile ( "cyclemeasure3:\n" " dec %[counter] \n" " dec %[counter] \n" " dec %[counter] \n" " dec %[counter] \n" " jnz cyclemeasure3 \n" : /* read/write reg */ [counter] "+r" (cycles), ); rdtsc(end); |
有一点需要注意:虽然每执行若干次dec 指令紧接着一次判断跳转指令jnz,但得益于现代CPU 的指令融合(称作instruction-fusion/micro-fusion,将比较指令及其之前的一个微指令合并为一个执行),jnz 并不会单独占用一个时钟周期,因此总的执行周期和cycles 初始值一致。
另外,rdtsc 通过X86 指令读取CPU cycle 计数器,如下
1 2 3 4 5 | #define rdtsc(u64) { \ uint32_t hi, lo; \ __asm__ __volatile__ ("RDTSC\n\t" : "=a" (lo), "=d" (hi)); \ u64 = ((uint64_t )hi << 32) | lo; \ } |
结果显示,实际运行的CPU cycles(end-start) 和变量cycles 非常接近。
通过计算最大IPC(Instruction Per Cycle)得到CPU 指令集并行数
单核CPU 在每个时钟周期可执行N 条指令,通过计算一个程序最大的IPC 即可(向上取整)近似得到N 的大小。基本思路是将(可并行的)M(>N)条指令同时执行,得到的IPC。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | int cycles[8] = {NUM, NUM, NUM, NUM, NUM, NUM, NUM, NUM}; rdtsc(start); __asm volatile ( "cyclemeasure8:\n" " dec %[counter] \n" " dec %[counter2] \n" " dec %[counter3] \n" " dec %[counter4] \n" " dec %[counter5] \n" " dec %[counter6] \n" " dec %[counter7] \n" " dec %[counter8] \n" " jnz cyclemeasure8 \n" : /* read/write reg */ [counter] "+r" (cycles[0]), [counter2] "+r" (cycles[1]), [counter3] "+r" (cycles[2]), [counter4] "+r" (cycles[3]), [counter5] "+r" (cycles[4]), [counter6] "+r" (cycles[5]), [counter7] "+r" (cycles[6]), [counter8] "+r" (cycles[7]) ); rdtsc(end); printf ( "IPC : %lf\n" , (8.0*NUM)/(end-start)); |
注意,一般N 的大小不会大于通用寄存器个数。
两个问题:
1 尝试将循环中指令修改为nop,但效果不如计算(inc/dec 无依赖关系的数据)好;
2 计算和IO (load/store)相关的指令执行器应该是不同的,它们之间的并行是不是使得理论IPC 应稍大于N?
调度和中断
调度和中断是运行时对结果最大两个因素:1 调度可通过设置进程优先级为SCHED_FIFO完成;2 中断在X86 系统中可通过cli/sti 指令关闭和开启(需要root 权限和IO 权限,即iopl(3))。
通过软件的方法多次测试,去除掉因为调度或中断导致的明显偏差测试也是一种方法,具有更好兼容性。
参考文献
https://lemire.me/blog/2019/05/19/measuring-the-system-clock-frequency-using-loops-intel-and-arm/