X86处理器可以通过CPUID 指令获取CPU 的基频(Base frequency),但嵌入式CPU 往往没有提供这样的指令,另一种朴素而有效的思路是:用高精度时钟计量N条指令执行时间来计算CPU 当前频率。这种方法在Linux 启动时也会用到,毕竟不是所有CPU 都支持CPUID 或类似指令。
简单而直观的例子
下面是一段简单而直观的例子,通过累加寄存器1000次(CPU 执行一次累加需要一个时钟周期)计量CPU 频率。
#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 次):
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
#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)); //计算频率
}
原因分析:部分汇编的代码是这样的。
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 小于期望
如果被测代码中自加的变量不止一个,如下
__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 可以在指令集并行。
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 或缺页中断,影响测试结果,上面示例中给除了基于循环的测试代码
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 计数器,如下
#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。
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/